From f6544d96457a179d62675459896518b3c12c468f Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Sun, 29 Mar 2020 13:24:09 +0200 Subject: [PATCH 01/43] new implementation of parsing and serialization This "conversion" process now has two levels: The low-level part is in `valuetype`, where ics value strings are converted to simple python objects and back, based on some externally determined VALUE type and without using any context or inspecting parameters. The high-level part is in `converter`. A `GenericConverter` can take multiple ContentLines or Containers and convert them into the respective values of a `Component`. For `AttributeConverter`s this is done for a single attribute of a `Component` type (which must be an `attr`s class), using metadata of the attribute to determine further information, such as value type, is required or multi value, ics name, etc. `AttributeValueConverter`s then use a `valuetype` converter to parse the simple attribute value. Other converters combine multiple `ContentLine`s into a single attribute, e.g. a `Timespan`, `Person`, or `rrule`. The `ComponentConverter`, which is created from the `Meta` attribute set on any `Component` subclass, inspects all attributes of the class and calls the respective converters. All unknown parameters are now also collected in a dict. This makes it a lot less work to add new attributes, as most conversion logic can be generated automatically and without any redundant code. Additionally, this makes it easier to implement correct handling of `VTIMEZONE`s in all places and will allow implementation of JSON-based ical handling and variable levels of parser strictness. I also included some further refactorings: the `tools` module with the broken online validation is gone (the website is offline), as all `Alarm`s now only take a few lines they have been merged into a single file, `ics.grammar.parse` has been shortened to `ics.grammar`, the inner `Meta` classes has been replaced by instances of an `attr`s class, the `Component` conversion methods are now called `from_container` and `to_container` and for `ContentLine`/`Container` there's now a `serialize` method to convert them to ics strings. The most important change might be that all custom `__str__` and `__repr__` methods were removed. They now default to what `attr` generates and in general follow the standard that `repr`s "should look like a valid Python expression that could be used to recreate an object with the same value". This makes debugging easier and allows us to implement `str` with a nice and short informal representation, and only generate the ics representation when this is intended. Previously, `__str__` returned the ics string and `__repr__` returned a very short informal description, which made dumping the actual python values hard when debugging. --- doc/event-cmp.rst | 15 +- ics/__init__.py | 29 ++- ics/__meta__.py | 2 + ics/alarm.py | 132 ++++++++++++ ics/alarm/__init__.py | 8 - ics/alarm/audio.py | 27 --- ics/alarm/base.py | 68 ------- ics/alarm/custom.py | 23 --- ics/alarm/display.py | 23 --- ics/alarm/email.py | 34 ---- ics/alarm/none.py | 18 -- ics/alarm/utils.py | 30 --- ics/attendee.py | 78 +------ ics/component.py | 103 ++++------ ics/converter/__init__.py | 0 ics/converter/base.py | 192 ++++++++++++++++++ ics/converter/component.py | 102 ++++++++++ ics/converter/special.py | 115 +++++++++++ ics/converter/timespan.py | 101 +++++++++ ics/converter/value.py | 136 +++++++++++++ ics/event.py | 49 ++--- ics/geo.py | 26 +++ ics/grammar/__init__.py | 219 ++++++++++++++++++++ ics/grammar/parse.py | 212 ------------------- ics/icalendar.py | 40 ++-- ics/parsers/alarm_parser.py | 70 ------- ics/parsers/attendee_parser.py | 39 ---- ics/parsers/event_parser.py | 104 ---------- ics/parsers/icalendar_parser.py | 84 -------- ics/parsers/parser.py | 47 ----- ics/parsers/todo_parser.py | 87 -------- ics/serializers/alarm_serializer.py | 68 ------- ics/serializers/attendee_serializer.py | 40 ---- ics/serializers/event_serializer.py | 131 ------------ ics/serializers/icalendar_serializer.py | 31 --- ics/serializers/serializer.py | 16 -- ics/serializers/todo_serializer.py | 87 -------- ics/timespan.py | 14 +- ics/todo.py | 10 +- ics/tools.py | 26 --- ics/types.py | 23 ++- ics/utils.py | 243 +++------------------- ics/valuetype/__init__.py | 0 ics/valuetype/base.py | 42 ++++ ics/valuetype/datetime.py | 259 ++++++++++++++++++++++++ ics/valuetype/generic.py | 158 +++++++++++++++ ics/valuetype/special.py | 24 +++ 47 files changed, 1659 insertions(+), 1726 deletions(-) create mode 100644 ics/alarm.py delete mode 100644 ics/alarm/__init__.py delete mode 100644 ics/alarm/audio.py delete mode 100644 ics/alarm/base.py delete mode 100644 ics/alarm/custom.py delete mode 100644 ics/alarm/display.py delete mode 100644 ics/alarm/email.py delete mode 100644 ics/alarm/none.py delete mode 100644 ics/alarm/utils.py create mode 100644 ics/converter/__init__.py create mode 100644 ics/converter/base.py create mode 100644 ics/converter/component.py create mode 100644 ics/converter/special.py create mode 100644 ics/converter/timespan.py create mode 100644 ics/converter/value.py create mode 100644 ics/geo.py delete mode 100644 ics/grammar/parse.py delete mode 100644 ics/parsers/alarm_parser.py delete mode 100644 ics/parsers/attendee_parser.py delete mode 100644 ics/parsers/event_parser.py delete mode 100644 ics/parsers/icalendar_parser.py delete mode 100644 ics/parsers/parser.py delete mode 100644 ics/parsers/todo_parser.py delete mode 100644 ics/serializers/alarm_serializer.py delete mode 100644 ics/serializers/attendee_serializer.py delete mode 100644 ics/serializers/event_serializer.py delete mode 100644 ics/serializers/icalendar_serializer.py delete mode 100644 ics/serializers/serializer.py delete mode 100644 ics/serializers/todo_serializer.py delete mode 100644 ics/tools.py create mode 100644 ics/valuetype/__init__.py create mode 100644 ics/valuetype/base.py create mode 100644 ics/valuetype/datetime.py create mode 100644 ics/valuetype/generic.py create mode 100644 ics/valuetype/special.py diff --git a/doc/event-cmp.rst b/doc/event-cmp.rst index e2c6c216..95eccdbb 100644 --- a/doc/event-cmp.rst +++ b/doc/event-cmp.rst @@ -65,15 +65,13 @@ attributes. :: >>> e = ics.Event() - >>> e - - >>> str(e) # doctest: +ELLIPSIS - 'BEGIN:VEVENT\r\nDTSTAMP:2020...\r\nUID:...@....org\r\nEND:VEVENT' + >>> e # doctest: +ELLIPSIS + Event(extra=Container('VEVENT', []), extra_params={}, _timespan=EventTimespan(begin_time=None, end_time=None, duration=None, precision='second'), name=None, uid='...@....org', description=None, location=None, url=None, status=None, created=None, last_modified=None, dtstamp=datetime.datetime(2020, ..., tzinfo=tzutc()), alarms=[], classification=None, transparent=None, organizer=None, geo=None, attendees=[], categories=[]) + >>> e.to_container().serialize() # doctest: +ELLIPSIS + 'BEGIN:VEVENT\r\nUID:...@....org\r\nDTSTAMP:2020...T...Z\r\nEND:VEVENT' >>> import attr, pprint >>> pprint.pprint(attr.asdict(e)) # doctest: +ELLIPSIS - {'_classmethod_args': None, - '_classmethod_kwargs': None, - '_timespan': {'begin_time': None, + {'_timespan': {'begin_time': None, 'duration': None, 'end_time': None, 'precision': 'second'}, @@ -83,8 +81,9 @@ attributes. 'classification': None, 'created': None, 'description': None, - 'dtstamp': datetime.datetime(2020, ...), + 'dtstamp': datetime.datetime(2020, ..., tzinfo=tzutc()), 'extra': [], + 'extra_params': {}, 'geo': None, 'last_modified': None, 'location': None, diff --git a/ics/__init__.py b/ics/__init__.py index bc388ca6..937d5b6a 100644 --- a/ics/__init__.py +++ b/ics/__init__.py @@ -1,25 +1,40 @@ -from .__meta__ import (__author__, __copyright__, __license__, # noqa - __title__, __version__) +def load_converters(): + from ics.converter.base import AttributeConverter + from ics.converter.component import ComponentConverter + from ics.converter.special import TimezoneConverter, AlarmConverter, PersonConverter, RecurrenceConverter + from ics.converter.timespan import TimespanConverter + from ics.converter.value import AttributeValueConverter + from ics.valuetype.base import ValueConverter + from ics.valuetype.datetime import DateConverter, DatetimeConverter, DurationConverter, PeriodConverter, TimeConverter, UTCOffsetConverter + from ics.valuetype.generic import BinaryConverter, BooleanConverter, CalendarUserAddressConverter, FloatConverter, IntegerConverter, RecurConverter, TextConverter, URIConverter + from ics.valuetype.special import GeoConverter + + +load_converters() # make sure that converters are initialized before any Component classes are defined + +from .__meta__ import * # noqa +from .__meta__ import __all__ as all_meta from .alarm import * # noqa from .alarm import __all__ as all_alarms from .attendee import Attendee, Organizer -from .event import Event, Geo -from .grammar.parse import Container, ContentLine +from .component import Component +from .event import Event +from .geo import Geo +from .grammar import Container, ContentLine from .icalendar import Calendar from .timespan import EventTimespan, Timespan, TodoTimespan from .todo import Todo __all__ = [ + *all_meta, *all_alarms, "Attendee", "Event", - "Geo", "Calendar", "Organizer", "Timespan", "EventTimespan", "TodoTimespan", "Todo", - "ContentLine", - "Container" + "Component" ] diff --git a/ics/__meta__.py b/ics/__meta__.py index 86f3424a..fec9635b 100644 --- a/ics/__meta__.py +++ b/ics/__meta__.py @@ -3,3 +3,5 @@ __author__ = "Nikita Marchant" __license__ = "Apache License, Version 2.0" __copyright__ = "Copyright 2013-2020 Nikita Marchant and individual contributors" + +__all__ = ["__title__", "__version__", "__author__", "__license__", "__copyright__"] diff --git a/ics/alarm.py b/ics/alarm.py new file mode 100644 index 00000000..07592634 --- /dev/null +++ b/ics/alarm.py @@ -0,0 +1,132 @@ +from abc import ABCMeta, abstractmethod +from datetime import datetime, timedelta +from typing import List, Optional, Union + +import attr +from attr.converters import optional as c_optional +from attr.validators import instance_of, optional as v_optional + +from ics.attendee import Attendee +from ics.component import Component +from ics.converter.component import ComponentMeta +from ics.converter.special import AlarmConverter +from ics.grammar import ContentLine +from ics.utils import call_validate_on_inst, check_is_instance, ensure_timedelta + +__all__ = ["BaseAlarm", "AudioAlarm", "CustomAlarm", "DisplayAlarm", "EmailAlarm", "NoneAlarm"] + + +@attr.s +class BaseAlarm(Component, metaclass=ABCMeta): + """ + A calendar event VALARM base class + """ + Meta = ComponentMeta("VALARM", converter_class=AlarmConverter) + + trigger: Union[timedelta, datetime, None] = attr.ib( + default=None, + validator=v_optional(instance_of((timedelta, datetime))) # type: ignore + ) # TODO is this relative to begin or end? + repeat: int = attr.ib(default=None, validator=call_validate_on_inst) + duration: timedelta = attr.ib(default=None, converter=c_optional(ensure_timedelta), validator=call_validate_on_inst) # type: ignore + + def validate(self, attr=None, value=None): + if self.repeat is not None: + if self.repeat < 0: + raise ValueError("Repeat must be great than or equal to 0.") + if self.duration is None: + raise ValueError( + "A definition of an alarm with a repeating trigger MUST include both the DURATION and REPEAT properties." + ) + + if self.duration is not None and self.duration.total_seconds() < 0: + raise ValueError("Alarm duration timespan must be positive.") + + @property + @abstractmethod + def action(self): + """ VALARM action to be implemented by concrete classes + """ + raise NotImplementedError("Base class cannot be instantiated directly") + + +@attr.s +class AudioAlarm(BaseAlarm): + """ + A calendar event VALARM with AUDIO option. + """ + + sound: Optional[ContentLine] = attr.ib(default=None, validator=v_optional(instance_of(ContentLine))) + + @property + def action(self): + return "AUDIO" + + +@attr.s +class CustomAlarm(BaseAlarm): + """ + A calendar event VALARM with custom ACTION. + """ + + _action = attr.ib(default=None) + + @property + def action(self): + return self._action + + +@attr.s +class DisplayAlarm(BaseAlarm): + """ + A calendar event VALARM with DISPLAY option. + """ + + display_text: str = attr.ib(default=None) + + @property + def action(self): + return "DISPLAY" + + +@attr.s +class EmailAlarm(BaseAlarm): + """ + A calendar event VALARM with Email option. + """ + + subject: str = attr.ib(default=None) + body: str = attr.ib(default=None) + recipients: List[Attendee] = attr.ib(factory=list) + + def add_recipient(self, recipient: Attendee): + """ Add an recipient to the recipients list """ + check_is_instance("recipient", recipient, Attendee) + self.recipients.append(recipient) + + @property + def action(self): + return "EMAIL" + + +class NoneAlarm(BaseAlarm): + """ + A calendar event VALARM with NONE option. + """ + + @property + def action(self): + return "NONE" + + +def get_type_from_action(action_type): + if action_type == "DISPLAY": + return DisplayAlarm + elif action_type == "AUDIO": + return AudioAlarm + elif action_type == "NONE": + return NoneAlarm + elif action_type == "EMAIL": + return EmailAlarm + else: + return CustomAlarm diff --git a/ics/alarm/__init__.py b/ics/alarm/__init__.py deleted file mode 100644 index a038f522..00000000 --- a/ics/alarm/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from ics.alarm.audio import AudioAlarm -from ics.alarm.base import BaseAlarm -from ics.alarm.custom import CustomAlarm -from ics.alarm.display import DisplayAlarm -from ics.alarm.email import EmailAlarm -from ics.alarm.none import NoneAlarm - -__all__ = ["BaseAlarm", "AudioAlarm", "DisplayAlarm", "EmailAlarm", "NoneAlarm", "CustomAlarm"] diff --git a/ics/alarm/audio.py b/ics/alarm/audio.py deleted file mode 100644 index d4a1cb8e..00000000 --- a/ics/alarm/audio.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import Optional - -import attr -from attr.validators import instance_of, optional as v_optional - -from ics.alarm.base import BaseAlarm -from ics.grammar.parse import ContentLine -from ics.parsers.alarm_parser import AudioAlarmParser -from ics.serializers.alarm_serializer import AudioAlarmSerializer - - -@attr.s(repr=False) -class AudioAlarm(BaseAlarm): - """ - A calendar event VALARM with AUDIO option. - """ - - class Meta: - name = "VALARM" - parser = AudioAlarmParser - serializer = AudioAlarmSerializer - - sound: Optional[ContentLine] = attr.ib(default=None, validator=v_optional(instance_of(ContentLine))) - - @property - def action(self): - return "AUDIO" diff --git a/ics/alarm/base.py b/ics/alarm/base.py deleted file mode 100644 index 52ed95b1..00000000 --- a/ics/alarm/base.py +++ /dev/null @@ -1,68 +0,0 @@ -from abc import ABCMeta, abstractmethod -from datetime import datetime, timedelta -from typing import Any, Type, Union - -import attr -from attr.converters import optional as c_optional -from attr.validators import instance_of, optional as v_optional - -from ics.component import Component, ComponentType -from ics.grammar.parse import Container -from ics.parsers.alarm_parser import BaseAlarmParser -from ics.serializers.alarm_serializer import BaseAlarmSerializer -from ics.utils import ensure_timedelta, get_lines - - -def call_validate_on_inst(inst, attr, value): - inst.validate(attr, value) - - -@attr.s(repr=False) -class BaseAlarm(Component, metaclass=ABCMeta): - """ - A calendar event VALARM base class - """ - - class Meta: - name = "VALARM" - parser = BaseAlarmParser - serializer = BaseAlarmSerializer - - trigger: Union[timedelta, datetime, None] = attr.ib( - default=None, - validator=v_optional(instance_of((timedelta, datetime))) # type: ignore - ) - repeat: int = attr.ib(default=None, validator=call_validate_on_inst) - duration: timedelta = attr.ib(default=None, converter=c_optional(ensure_timedelta), validator=call_validate_on_inst) # type: ignore - - def validate(self, attr=None, value=None): - if self.repeat is not None: - if self.repeat < 0: - raise ValueError("Repeat must be great than or equal to 0.") - if self.duration is None: - raise ValueError( - "A definition of an alarm with a repeating trigger MUST include both the DURATION and REPEAT properties." - ) - - if self.duration is not None and self.duration.total_seconds() < 0: - raise ValueError("Alarm duration timespan must be positive.") - - @classmethod - def _from_container(cls: Type[ComponentType], container: Container, *args: Any, **kwargs: Any) -> ComponentType: - ret = super(BaseAlarm, cls)._from_container(container, *args, **kwargs) # type: ignore - get_lines(ret.extra, "ACTION", keep=False) # Just drop the ACTION line - return ret - - @property - @abstractmethod - def action(self): - """ VALARM action to be implemented by concrete classes - """ - raise NotImplementedError("Base class cannot be instantiated directly") - - def __repr__(self): - value = "{0} trigger:{1}".format(type(self).__name__, self.trigger) - if self.repeat: - value += " repeat:{0} duration:{1}".format(self.repeat, self.duration) - - return "<{0}>".format(value) diff --git a/ics/alarm/custom.py b/ics/alarm/custom.py deleted file mode 100644 index 8a229a41..00000000 --- a/ics/alarm/custom.py +++ /dev/null @@ -1,23 +0,0 @@ -import attr - -from ics.alarm.base import BaseAlarm -from ics.parsers.alarm_parser import CustomAlarmParser -from ics.serializers.alarm_serializer import CustomAlarmSerializer - - -@attr.s(repr=False) -class CustomAlarm(BaseAlarm): - """ - A calendar event VALARM with custom ACTION. - """ - - class Meta: - name = "VALARM" - parser = CustomAlarmParser - serializer = CustomAlarmSerializer - - _action = attr.ib(default=None) - - @property - def action(self): - return self._action diff --git a/ics/alarm/display.py b/ics/alarm/display.py deleted file mode 100644 index 4bec247c..00000000 --- a/ics/alarm/display.py +++ /dev/null @@ -1,23 +0,0 @@ -import attr - -from ics.alarm.base import BaseAlarm -from ics.parsers.alarm_parser import DisplayAlarmParser -from ics.serializers.alarm_serializer import DisplayAlarmSerializer - - -@attr.s(repr=False) -class DisplayAlarm(BaseAlarm): - """ - A calendar event VALARM with DISPLAY option. - """ - - class Meta: - name = "VALARM" - parser = DisplayAlarmParser - serializer = DisplayAlarmSerializer - - display_text: str = attr.ib(default=None) - - @property - def action(self): - return "DISPLAY" diff --git a/ics/alarm/email.py b/ics/alarm/email.py deleted file mode 100644 index a8095dd4..00000000 --- a/ics/alarm/email.py +++ /dev/null @@ -1,34 +0,0 @@ -from typing import List - -import attr - -from ics.alarm.base import BaseAlarm -from ics.attendee import Attendee -from ics.parsers.alarm_parser import EmailAlarmParser -from ics.serializers.alarm_serializer import EmailAlarmSerializer -from ics.utils import check_is_instance - - -@attr.s(repr=False) -class EmailAlarm(BaseAlarm): - """ - A calendar event VALARM with Email option. - """ - - class Meta: - name = "VALARM" - parser = EmailAlarmParser - serializer = EmailAlarmSerializer - - subject: str = attr.ib(default=None) - body: str = attr.ib(default=None) - recipients: List[Attendee] = attr.ib(factory=list) # TODO this is a set for Event - - def add_recipient(self, recipient: Attendee): - """ Add an recipient to the recipients list """ - check_is_instance("recipient", recipient, Attendee) - self.recipients.append(recipient) - - @property - def action(self): - return "EMAIL" diff --git a/ics/alarm/none.py b/ics/alarm/none.py deleted file mode 100644 index 44ee0dee..00000000 --- a/ics/alarm/none.py +++ /dev/null @@ -1,18 +0,0 @@ -from ics.alarm.base import BaseAlarm -from ics.parsers.alarm_parser import NoneAlarmParser -from ics.serializers.alarm_serializer import NoneAlarmSerializer - - -class NoneAlarm(BaseAlarm): - """ - A calendar event VALARM with NONE option. - """ - - class Meta: - name = "VALARM" - parser = NoneAlarmParser - serializer = NoneAlarmSerializer - - @property - def action(self): - return "NONE" diff --git a/ics/alarm/utils.py b/ics/alarm/utils.py deleted file mode 100644 index 9ce03296..00000000 --- a/ics/alarm/utils.py +++ /dev/null @@ -1,30 +0,0 @@ -from ics.grammar.parse import ContentLine -from ics.utils import get_lines - - -def get_type_from_action(action_type): - if action_type == "DISPLAY": - from ics.alarm import DisplayAlarm - return DisplayAlarm - elif action_type == "AUDIO": - from ics.alarm import AudioAlarm - return AudioAlarm - elif action_type == "NONE": - from ics.alarm import NoneAlarm - return NoneAlarm - elif action_type == 'EMAIL': - from ics.alarm import EmailAlarm - return EmailAlarm - else: - from ics.alarm import CustomAlarm - return CustomAlarm - - -def get_type_from_container(container): - action_type_lines = get_lines(container, "ACTION", keep=True) - if len(action_type_lines) > 1: - raise ValueError("Too many ACTION parameters in VALARM") - - action_type = action_type_lines[0] - assert isinstance(action_type, ContentLine) - return get_type_from_action(action_type.value) diff --git a/ics/attendee.py b/ics/attendee.py index 8bbc99cd..330bc50c 100644 --- a/ics/attendee.py +++ b/ics/attendee.py @@ -1,14 +1,8 @@ -import warnings -from typing import Dict, List, Optional, Type, TypeVar +from typing import Dict, List, Optional import attr -from ics.grammar.parse import ContentLine -from ics.parsers.attendee_parser import AttendeeParser, PersonParser -from ics.serializers.attendee_serializer import AttendeeSerializer, PersonSerializer -from ics.utils import escape_string, unescape_string - -PersonType = TypeVar('PersonType', bound='Person') +from ics.converter.component import ComponentMeta @attr.s @@ -19,70 +13,11 @@ class Person(object): sent_by: Optional[str] = attr.ib(default=None) extra: Dict[str, List[str]] = attr.ib(factory=dict) - def __attrs_post_init__(self): - if not self.common_name: - self.common_name = self.email - - class Meta: - name = "ABSTRACT-PERSON" - parser = PersonParser - serializer = PersonSerializer - - @classmethod - def parse(cls: Type[PersonType], line: ContentLine) -> PersonType: - email = unescape_string(line.value) - if email.lower().startswith("mailto:"): - email = email[len("mailto:"):] - val = cls(email) - val.populate(line) - return val - - def populate(self, line: ContentLine) -> None: - if line.name != self.Meta.name: - raise ValueError("line isn't an {}".format(self.Meta.name)) - - params = dict(line.params) - for param_name, (parser, options) in self.Meta.parser.get_parsers().items(): - values = params.pop(param_name, []) - if not values and options.required: - if options.default: - values = options.default - default_str = "\\n".join(map(str, options.default)) - message = ("The %s property was not found and is required by the RFC." + - " A default value of \"%s\" has been used instead") % (param_name, default_str) - warnings.warn(message) - else: - raise ValueError('A {} must have at least one {}'.format(line.name, param_name)) - - if not options.multiple and len(values) > 1: - raise ValueError('A {} must have at most one {}'.format(line.name, param_name)) - - if options.multiple: - parser(self, values) # Send a list or empty list - else: - if len(values) == 1: - parser(self, values[0]) # Send the element - else: - parser(self, None) # Send None - - self.extra = params # Store unused lines - - def serialize(self) -> ContentLine: - line = ContentLine(self.Meta.name, params=self.extra, value=escape_string('mailto:%s' % self.email)) - for output in self.Meta.serializer.get_serializers(): - output(self, line) - return line - - def __str__(self) -> str: - """Returns the attendee in an ContentLine format.""" - return str(self.serialize()) + Meta = ComponentMeta("ABSTRACT-PERSON") class Organizer(Person): - class Meta: - name = 'ORGANIZER' - parser = PersonParser - serializer = PersonSerializer + Meta = ComponentMeta("ORGANIZER") @attr.s @@ -92,7 +27,4 @@ class Attendee(Person): partstat: Optional[str] = attr.ib(default=None) cutype: Optional[str] = attr.ib(default=None) - class Meta: - name = 'ATTENDEE' - parser = AttendeeParser - serializer = AttendeeSerializer + Meta = ComponentMeta("ATTENDEE") diff --git a/ics/component.py b/ics/component.py index d7e2cf0c..2c75f1f9 100644 --- a/ics/component.py +++ b/ics/component.py @@ -1,90 +1,61 @@ -import warnings -from typing import Any, Dict, Tuple, Type, TypeVar +from typing import ClassVar, Dict, List, Type, TypeVar, Union import attr from attr.validators import instance_of -from ics.grammar.parse import Container -from ics.parsers.parser import Parser -from ics.serializers.serializer import Serializer +from ics.converter.component import ComponentMeta, InflatedComponentMeta +from ics.grammar import Container from ics.types import RuntimeAttrValidation -from ics.utils import get_lines -ComponentType = TypeVar('ComponentType', bound='Component') PLACEHOLDER_CONTAINER = Container("PLACEHOLDER") +ComponentType = TypeVar('ComponentType', bound='Component') +ExtraParams = Dict[str, List[str]] +ComponentExtraParams = Dict[str, Union[ExtraParams, List[ExtraParams]]] @attr.s class Component(RuntimeAttrValidation): - class Meta: - name = "ABSTRACT" - parser = Parser - serializer = Serializer + Meta: ClassVar[Union[ComponentMeta, InflatedComponentMeta]] = ComponentMeta("ABSTRACT-COMPONENT") - extra: Container = attr.ib(init=False, default=PLACEHOLDER_CONTAINER, validator=instance_of(Container)) - _classmethod_args: Tuple = attr.ib(init=False, default=None, repr=False, eq=False, order=False, hash=False) - _classmethod_kwargs: Dict = attr.ib(init=False, default=None, repr=False, eq=False, order=False, hash=False) + extra: Container = attr.ib(init=False, default=PLACEHOLDER_CONTAINER, validator=instance_of(Container), metadata={"ics_ignore": True}) + extra_params: ComponentExtraParams = attr.ib(init=False, factory=dict, validator=instance_of(dict), metadata={"ics_ignore": True}) def __attrs_post_init__(self): super(Component, self).__attrs_post_init__() if self.extra is PLACEHOLDER_CONTAINER: - self.extra = Container(self.Meta.name) + self.extra = Container(self.Meta.container_name) def __init_subclass__(cls): super().__init_subclass__() - if cls.__str__ != Component.__str__: - raise TypeError("%s may not overwrite %s" % (cls, Component.__str__)) + cls.Meta.inflate(cls) @classmethod - def _from_container(cls: Type[ComponentType], container: Container, *args: Any, **kwargs: Any) -> ComponentType: - k = cls() - k._classmethod_args = args - k._classmethod_kwargs = kwargs - k._populate(container) - return k - - def _populate(self, container: Container) -> None: - if container.name != self.Meta.name: - raise ValueError("container isn't an {}".format(self.Meta.name)) - - for line_name, (parser, options) in self.Meta.parser.get_parsers().items(): - lines = get_lines(container, line_name) - if not lines and options.required: - if options.default: - lines = options.default - default_str = "\\n".join(map(str, options.default)) - message = ("The %s property was not found and is required by the RFC." + - " A default value of \"%s\" has been used instead") % (line_name, default_str) - warnings.warn(message) - else: - raise ValueError('A {} must have at least one {}'.format(container.name, line_name)) - - if not options.multiple and len(lines) > 1: - raise ValueError('A {} must have at most one {}'.format(container.name, line_name)) - - if options.multiple: - parser(self, lines) # Send a list or empty list - else: - if len(lines) == 1: - parser(self, lines[0]) # Send the element - else: - parser(self, None) # Send None - - self.extra = container # Store unused lines + def from_container(cls: Type[ComponentType], container: Container) -> ComponentType: + return cls.Meta.load_instance(container) # type: ignore + + def to_container(self): + return self.Meta.serialize_toplevel(self) # type: ignore + + def strip_extras(self, all_extras=False, extra_properties=None, extra_params=None, property_merging=None): + if extra_properties is None: + extra_properties = all_extras + if extra_params is None: + extra_params = all_extras + if property_merging is None: + property_merging = all_extras + if not any([extra_properties, extra_params, property_merging]): + raise ValueError("need to strip at least one thing") + if extra_properties: + self.extra.clear() + if extra_params: + self.extra_params.clear() + elif property_merging: + for val in self.extra_params.values(): + if not isinstance(val, list): continue + for v in val: + v.pop("__merge_next", None) def clone(self): - """ - Returns: - Event: an exact copy of self - """ + """Returns an exact (shallow) copy of self""" + # TODO deep copies? return attr.evolve(self) - - def serialize(self) -> Container: - container = self.extra.clone() - for output in self.Meta.serializer.get_serializers(): - output(self, container) - return container - - def __str__(self) -> str: - """Returns the component in an iCalendar format.""" - return str(self.serialize()) diff --git a/ics/converter/__init__.py b/ics/converter/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ics/converter/base.py b/ics/converter/base.py new file mode 100644 index 00000000..982c67ed --- /dev/null +++ b/ics/converter/base.py @@ -0,0 +1,192 @@ +import abc +import warnings +from typing import Any, ClassVar, Dict, List, MutableSequence, Optional, TYPE_CHECKING, Tuple, Type, Union, cast + +import attr + +from ics.grammar import Container +from ics.types import ContainerItem + +if TYPE_CHECKING: + from ics.component import Component, ExtraParams + from ics.converter.component import InflatedComponentMeta + + +class GenericConverter(abc.ABC): + @property + @abc.abstractmethod + def priority(self) -> int: + pass + + @property + @abc.abstractmethod + def filter_ics_names(self) -> List[str]: + pass + + @abc.abstractmethod + def populate(self, component: "Component", item: ContainerItem, context: Dict) -> bool: + """ + :param context: + :param component: + :param item: + :return: True, if the line was consumed and shouldn't be stored as extra (but might still be passed on) + """ + pass + + def finalize(self, component: "Component", context: Dict): + pass + + @abc.abstractmethod + def serialize(self, component: "Component", output: Container, context: Dict): + pass + + +@attr.s(frozen=True) +class AttributeConverter(GenericConverter, abc.ABC): + BY_TYPE: ClassVar[Dict[Type, Type["AttributeConverter"]]] = {} + + attribute: attr.Attribute = attr.ib() + + multi_value_type: Optional[Type[MutableSequence]] + value_type: Type + value_types: List[Type] + _priority: int + is_required: bool + + def __attrs_post_init__(self): + multi_value_type, value_type, value_types = extract_attr_type(self.attribute) + _priority = self.attribute.metadata.get("ics_priority", self.default_priority) + is_required = self.attribute.metadata.get("ics_required", None) + if is_required is None: + if not self.attribute.init: + is_required = False + elif self.attribute.default is not attr.NOTHING: + is_required = False + else: + is_required = True + for key, value in locals().items(): # all variables created in __attrs_post_init__ will be set on self + if key == "self" or key.startswith("__"): continue + object.__setattr__(self, key, value) + + def _check_component(self, component: "Component", context: Dict): + if context[(self, "current_component")] is None: + context[(self, "current_component")] = component + context[(self, "current_value_count")] = 0 + else: + if context[(self, "current_component")] is not component: + raise ValueError("must call finalize before call to populate with another component") + + def finalize(self, component: "Component", context: Dict): + context[(self, "current_component")] = None + context[(self, "current_value_count")] = 0 + + def set_or_append_value(self, component: "Component", value: Any): + if self.multi_value_type is not None: + container = getattr(component, self.attribute.name) + if container is None: + container = self.multi_value_type() + setattr(component, self.attribute.name, container) + container.append(value) + else: + setattr(component, self.attribute.name, value) + + def get_value(self, component: "Component") -> Any: + return getattr(component, self.attribute.name) + + def get_value_list(self, component: "Component") -> List[Any]: + if self.is_multi_value: + return list(self.get_value(component)) + else: + return [self.get_value(component)] + + def set_or_append_extra_params(self, component: "Component", value: "ExtraParams", name: Optional[str] = None): + name = name or self.attribute.name + if self.is_multi_value: + extras = component.extra_params.setdefault(name, []) + cast(List["ExtraParams"], extras).append(value) + elif value: + component.extra_params[name] = value + + def get_extra_params(self, component: "Component", name: Optional[str] = None) -> Union["ExtraParams", List["ExtraParams"]]: + default: Union["ExtraParams", List["ExtraParams"]] = list() if self.multi_value_type else dict() + name = name or self.attribute.name + return component.extra_params.get(name, default) + + @property + def default_priority(self) -> int: + return 0 + + @property + def priority(self) -> int: + return self._priority + + @property + def is_multi_value(self) -> bool: + return self.multi_value_type is not None + + @staticmethod + def get_converter_for(attribute: attr.Attribute) -> Optional["AttributeConverter"]: + if attribute.metadata.get("ics_ignore", not attribute.init): + return None + converter = attribute.metadata.get("ics_converter", None) + if converter: + return converter(attribute) + + multi_value_type, value_type, value_types = extract_attr_type(attribute) + if len(value_types) == 1: + assert [value_type] == value_types + from ics.component import Component + if issubclass(value_type, Component): + meta: "InflatedComponentMeta" = cast("InflatedComponentMeta", value_type.Meta) + return meta(attribute) + elif value_type in AttributeConverter.BY_TYPE: + return AttributeConverter.BY_TYPE[value_type](attribute) + + from ics.converter.value import AttributeValueConverter + return AttributeValueConverter(attribute) + + +def extract_attr_type(attribute: attr.Attribute) -> Tuple[Optional[Type[MutableSequence]], Type, List[Type]]: + attr_type = attribute.metadata.get("ics_type", attribute.type) + if attr_type is None: + raise ValueError("can't convert attribute %s with AttributeConverter, " + "as it has no type information" % attribute) + generic_origin = getattr(attr_type, "__origin__", attr_type) + generic_vars = getattr(attr_type, "__args__", tuple()) + + if generic_origin == Union: + generic_vars = [v for v in generic_vars if v is not type(None)] + if len(generic_vars) > 1: + return None, generic_origin[tuple(generic_vars)], list(generic_vars) + else: + return None, generic_vars[0], [generic_vars[0]] + + elif issubclass(generic_origin, MutableSequence): + if len(generic_vars) > 1: + warnings.warn("using first parameter for List type %s" % attr_type) + return generic_origin, generic_vars[0], [generic_vars[0]] + + else: + return None, attr_type, [attr_type] + + +def ics_attr_meta(name: str = None, + ignore: bool = None, + type: Type = None, + required: bool = None, + priority: int = None, + converter: Type[AttributeConverter] = None) -> Dict[str, Any]: + data: Dict[str, Any] = {} + if name: + data["ics_name"] = name + if ignore is not None: + data["ics_ignore"] = ignore + if type is not None: + data["ics_type"] = type + if required is not None: + data["ics_required"] = required + if priority is not None: + data["ics_priority"] = priority + if converter is not None: + data["ics_converter"] = converter + return data diff --git a/ics/converter/component.py b/ics/converter/component.py new file mode 100644 index 00000000..886b5d2a --- /dev/null +++ b/ics/converter/component.py @@ -0,0 +1,102 @@ +from collections import defaultdict +from typing import Dict, Iterable, List, Optional, TYPE_CHECKING, Tuple, Type, cast + +import attr +from attr import Attribute + +from ics.converter.base import AttributeConverter, GenericConverter +from ics.grammar import Container +from ics.types import ContainerItem + +if TYPE_CHECKING: + from ics.component import Component + + +@attr.s(frozen=True) +class ComponentMeta(object): + container_name: str = attr.ib() + converter_class: Type["ComponentConverter"] = attr.ib(default=None) + + def inflate(self, component_type: Type["Component"]): + if component_type.Meta is not self: + raise ValueError("can't inflate %s for %s, it's meta is %s" % (self, component_type, component_type.Meta)) + converters = cast(Iterable["AttributeConverter"], filter(bool, ( + AttributeConverter.get_converter_for(a) + for a in attr.fields(component_type) + ))) + component_type.Meta = InflatedComponentMeta( + component_type=component_type, + converters=tuple(sorted(converters, key=lambda c: c.priority)), + container_name=self.container_name, + converter_class=self.converter_class or ComponentConverter) + + +@attr.s(frozen=True) +class InflatedComponentMeta(ComponentMeta): + converters: Tuple[GenericConverter, ...] = attr.ib(default=None) + component_type: Type["Component"] = attr.ib(default=None) + + converter_lookup: Dict[str, List[GenericConverter]] + + def __attrs_post_init__(self): + object.__setattr__(self, "converter_lookup", defaultdict(list)) + for converter in self.converters: + for name in converter.filter_ics_names: + self.converter_lookup[name].append(converter) + + def __call__(self, attribute: Attribute): + return self.converter_class(attribute, self) + + def load_instance(self, container: Container, context: Optional[Dict] = None): + instance = self.component_type() + self.populate_instance(instance, container, context) + return instance + + def populate_instance(self, instance: "Component", container: Container, context: Optional[Dict] = None): + if container.name != self.container_name: + raise ValueError("container isn't an {}".format(self.container_name)) + if not context: + context = defaultdict(lambda: None) + + for line in container: + consumed = False + for conv in self.converter_lookup[line.name]: + if conv.populate(instance, line, context): + consumed = True + if not consumed: + instance.extra.append(line) + + for conv in self.converters: + conv.finalize(instance, context) + + def serialize_toplevel(self, component: "Component", context: Optional[Dict] = None): + if not context: + context = defaultdict(lambda: None) + container = Container(self.container_name) + for conv in self.converters: + conv.serialize(component, container, context) + container.extend(component.extra) + return container + + +@attr.s(frozen=True) +class ComponentConverter(AttributeConverter): + meta: InflatedComponentMeta = attr.ib() + + @property + def filter_ics_names(self) -> List[str]: + return [self.meta.container_name] + + def populate(self, component: "Component", item: ContainerItem, context: Dict) -> bool: + assert isinstance(item, Container) + self._check_component(component, context) + self.set_or_append_value(component, self.meta.load_instance(item, context)) + return True + + def serialize(self, parent: "Component", output: Container, context: Dict): + self._check_component(parent, context) + extras = self.get_extra_params(parent) + if extras: + raise ValueError("ComponentConverter %s can't serialize extra params %s", (self, extras)) + for value in self.get_value_list(parent): + output.append(self.meta.serialize_toplevel(value, context)) diff --git a/ics/converter/special.py b/ics/converter/special.py new file mode 100644 index 00000000..8d46cd36 --- /dev/null +++ b/ics/converter/special.py @@ -0,0 +1,115 @@ +from datetime import tzinfo +from io import StringIO +from typing import Dict, List, TYPE_CHECKING + +from dateutil.rrule import rruleset +from dateutil.tz import tzical + +from ics.attendee import Attendee, Organizer, Person +from ics.converter.base import AttributeConverter +from ics.converter.component import ComponentConverter +from ics.grammar import Container, ContentLine +from ics.types import ContainerItem + +if TYPE_CHECKING: + from ics.component import Component + + +class TimezoneConverter(AttributeConverter): + @property + def filter_ics_names(self) -> List[str]: + return ["VTIMEZONE"] + + def populate(self, component: "Component", item: ContainerItem, context: Dict) -> bool: + assert isinstance(item, Container) + self._check_component(component, context) + + item = item.clone([ + line for line in item if + not line.name.startswith("X-") and + not line.name == "SEQUENCE" + ]) + + fake_file = StringIO() + fake_file.write(item.serialize()) # Represent the block as a string + fake_file.seek(0) + timezones = tzical(fake_file) # tzical does not like strings + + # timezones is a tzical object and could contain multiple timezones + print("got timezone", timezones.keys(), timezones.get()) + return True + + def serialize(self, component: "Component", output: Container, context: Dict): + raise NotImplementedError("Timezones can't be serialized") + + +AttributeConverter.BY_TYPE[tzinfo] = TimezoneConverter + + +class RecurrenceConverter(AttributeConverter): + # TODO handle extras? + # TODO pass and handle available_tz / tzinfos + + @property + def filter_ics_names(self) -> List[str]: + return ["RRULE", "RDATE", "EXRULE", "EXDATE", "DTSTART"] + + def populate(self, component: "Component", item: ContainerItem, context: Dict) -> bool: + assert isinstance(item, ContentLine) + self._check_component(component, context) + # self.lines.append(item) + return False + + def finalize(self, component: "Component", context: Dict): + self._check_component(component, context) + # rrulestr("\r\n".join(self.lines), tzinfos={}, compatible=True) + + def serialize(self, component: "Component", output: Container, context: Dict): + pass + # value = rruleset() + # for rrule in value._rrule: + # output.append(ContentLine("RRULE", value=re.match("^RRULE:(.*)$", str(rrule)).group(1))) + # for exrule in value._exrule: + # output.append(ContentLine("EXRULE", value=re.match("^RRULE:(.*)$", str(exrule)).group(1))) + # for rdate in value._rdate: + # output.append(ContentLine(name="RDATE", value=DatetimeConverter.INST.serialize(rdate))) + # for exdate in value._exdate: + # output.append(ContentLine(name="EXDATE", value=DatetimeConverter.INST.serialize(exdate))) + + +AttributeConverter.BY_TYPE[rruleset] = RecurrenceConverter + + +class PersonConverter(AttributeConverter): + # TODO handle lists + + @property + def filter_ics_names(self) -> List[str]: + return [] + + def populate(self, component: "Component", item: ContainerItem, context: Dict) -> bool: + assert isinstance(item, ContentLine) + self._check_component(component, context) + return False + + def serialize(self, component: "Component", output: Container, context: Dict): + pass + + +AttributeConverter.BY_TYPE[Person] = PersonConverter +AttributeConverter.BY_TYPE[Attendee] = PersonConverter +AttributeConverter.BY_TYPE[Organizer] = PersonConverter + + +class AlarmConverter(ComponentConverter): + def populate(self, component: "Component", item: ContainerItem, context: Dict) -> bool: + # TODO handle trigger: Union[timedelta, datetime, None] before duration + assert isinstance(item, Container) + self._check_component(component, context) + + from ics.alarm import get_type_from_action + alarm_type = get_type_from_action(item) + instance = alarm_type() + alarm_type.Meta.populate_instance(instance, item, context) + self.set_or_append_value(component, instance) + return True diff --git a/ics/converter/timespan.py b/ics/converter/timespan.py new file mode 100644 index 00000000..4bf6c3e1 --- /dev/null +++ b/ics/converter/timespan.py @@ -0,0 +1,101 @@ +from typing import Dict, List, TYPE_CHECKING, cast + +from ics.converter.base import AttributeConverter +from ics.grammar import Container, ContentLine +from ics.timespan import EventTimespan, Timespan, TodoTimespan +from ics.types import ContainerItem +from ics.utils import ensure_datetime +from ics.valuetype.datetime import DateConverter, DatetimeConverter, DurationConverter + +if TYPE_CHECKING: + from ics.component import Component, ExtraParams + + +class TimespanConverter(AttributeConverter): + @property + def default_priority(self) -> int: + return 10000 + + @property + def filter_ics_names(self) -> List[str]: + return ["DTSTART", "DTEND", "DUE", "DURATION"] + + def populate(self, component: "Component", item: ContainerItem, context: Dict) -> bool: + assert isinstance(item, ContentLine) + self._check_component(component, context) + params = dict(item.params) + if item.name in ["DTSTART", "DTEND", "DUE"]: + value_type = params.pop("VALUE", ["DATE-TIME"]) + if value_type == ["DATE-TIME"]: + precision = "second" + elif value_type == ["DATE"]: + precision = "day" + else: + raise ValueError("can't handle %s with value type %s" % (item.name, value_type)) + + if context["timespan_precision"] is None: + context["timespan_precision"] = precision + else: + if context["timespan_precision"] != precision: + raise ValueError("event with diverging begin and end time precision") + + if precision == "day": + value = DateConverter.INST.parse(item.value) + else: + assert precision == "second" + value = DatetimeConverter.INST.parse(item.value) + + if item.name == "DTSTART": + self.set_or_append_extra_params(component, params, name="begin") + context["timespan_begin_time"] = value + else: + self.set_or_append_extra_params(component, params, name="end") # FIXME due? + context["timespan_end_time"] = value + + else: + assert item.name == "DURATION" + self.set_or_append_extra_params(component, params, name="duration") + context["timespan_duration"] = DurationConverter.INST.parse(item.value) + + return True + + def finalize(self, component: "Component", context: Dict): + self._check_component(component, context) + self.set_or_append_value(component, self.value_type( + ensure_datetime(context["timespan_begin_time"]), + ensure_datetime(context["timespan_end_time"]), + context["timespan_duration"], context["timespan_precision"])) + super(TimespanConverter, self).finalize(component, context) + + def serialize(self, component: "Component", output: Container, context: Dict): + self._check_component(component, context) + value: Timespan = self.get_value(component) + if value.is_all_day(): + value_type = {"VALUE": ["DATE"]} + dt_conv = DateConverter.INST + else: + value_type = {"VALUE": ["DATE-TIME"]} + dt_conv = DatetimeConverter.INST + + if value.get_begin(): + params: "ExtraParams" = cast("ExtraParams", self.get_extra_params(component, "begin")) + params = dict(**params, **value_type) + output.append(ContentLine(name="DTSTART", params=params, value=dt_conv.serialize(value.get_begin()))) + + if value.get_end_representation() == "end": + end_name = {"end": "DTEND", "due": "DUE"}[value._end_name()] + params = cast("ExtraParams", self.get_extra_params(component, end_name)) + params = dict(**params, **value_type) + output.append(ContentLine(name=end_name, params=params, value=dt_conv.serialize(value.get_effective_end()))) + + elif value.get_end_representation() == "duration": + params = cast("ExtraParams", self.get_extra_params(component, "duration")) + output.append(ContentLine( + name="DURATION", + params=params, + value=DurationConverter.INST.serialize(value.get_effective_duration()))) + + +AttributeConverter.BY_TYPE[Timespan] = TimespanConverter +AttributeConverter.BY_TYPE[EventTimespan] = TimespanConverter +AttributeConverter.BY_TYPE[TodoTimespan] = TimespanConverter diff --git a/ics/converter/value.py b/ics/converter/value.py new file mode 100644 index 00000000..630e7a29 --- /dev/null +++ b/ics/converter/value.py @@ -0,0 +1,136 @@ +from typing import Any, Dict, List, TYPE_CHECKING, Tuple, cast + +import attr + +from ics.converter.base import AttributeConverter +from ics.grammar import Container, ContentLine +from ics.types import ContainerItem +from ics.valuetype.base import ValueConverter + +if TYPE_CHECKING: + from ics.component import Component, ExtraParams + + +@attr.s(frozen=True) +class AttributeValueConverter(AttributeConverter): + value_converters: List[ValueConverter] + + def __attrs_post_init__(self): + super(AttributeValueConverter, self).__attrs_post_init__() + object.__setattr__(self, "value_converters", []) + for value_type in self.value_types: + converter = ValueConverter.BY_TYPE.get(value_type, None) + if converter is None: + raise ValueError("can't convert %s with ValueConverter" % value_type) + self.value_converters.append(converter) + + @property + def filter_ics_names(self) -> List[str]: + return [self.ics_name] + + @property + def ics_name(self) -> str: + name = self.attribute.metadata.get("ics_name", None) + if not name: + name = self.attribute.name.upper().replace("_", "-").strip("-") + return name + + def __find_line_converter(self, line: "ContentLine") -> Tuple["ExtraParams", ValueConverter]: + params = line.params + value_type = params.pop("VALUE", None) + if value_type: + if len(value_type) != 1: + raise ValueError("multiple VALUE type definitions in %s" % line) + for converter in self.value_converters: + if converter.ics_type == value_type[0]: + break + else: + raise ValueError("can't convert %s with %s" % (line, self)) + else: + converter = self.value_converters[0] + return params, converter + + def populate(self, component: "Component", item: ContainerItem, context: Dict) -> bool: + assert isinstance(item, ContentLine) + self._check_component(component, context) + if self.is_multi_value: + params = None + for value in item.value_list: + context[(self, "current_value_count")] += 1 + params, converter = self.__find_line_converter(item) + params["__merge_next"] = True # type: ignore + self.set_or_append_extra_params(component, params) + self.set_or_append_value(component, converter.parse(value)) + if params is not None: + params["__merge_next"] = False # type: ignore + else: + if context[(self, "current_value_count")] > 0: + raise ValueError("attribute %s can only be set once, second occurrence is %s" % (self.ics_name, item)) + context[(self, "current_value_count")] += 1 + params, converter = self.__find_line_converter(item) + self.set_or_append_extra_params(component, params) + self.set_or_append_value(component, converter.parse(item.value)) + return True + + def finalize(self, component: "Component", context: Dict): + self._check_component(component, context) + if self.is_required and context[(self, "current_value_count")] < 1: + raise ValueError("attribute %s is required but got no value" % self.ics_name) + super(AttributeValueConverter, self).finalize(component, context) + + def __find_value_converter(self, params: "ExtraParams", value: Any) -> ValueConverter: + for nr, converter in enumerate(self.value_converters): + if not isinstance(value, converter.python_type): continue + if nr > 0: + params["VALUE"] = [converter.ics_type] + return converter + else: + raise ValueError("can't convert %s with %s" % (value, self)) + + def serialize(self, component: "Component", output: Container, context: Dict): + if self.is_multi_value: + self.__serialize_multi(component, output, context) + else: + value = self.get_value(component) + if value: + params = cast("ExtraParams", self.get_extra_params(component)) + converter = self.__find_value_converter(params, value) + output.append(ContentLine( + name=self.ics_name, + params=params, + value=converter.serialize(value))) + + def __serialize_multi(self, component: "Component", output: "Container", context: Dict): + extra_params = cast(List["ExtraParams"], self.get_extra_params(component)) + values = self.get_value_list(component) + if len(extra_params) != len(values): + raise ValueError("length of extra params doesn't match length of parameters" + " for attribute %s of %r" % (self.attribute.name, component)) + + merge_next = False + current_params = None + current_values = [] + + for value, params in zip(values, extra_params): + merge_next = False + params = dict(params) + if params and params.pop("__merge_next", False): # type: ignore + merge_next = True + converter = self.__find_value_converter(params, value) + + if current_params is not None: + if current_params != params: + raise ValueError() + else: + current_params = params + current_values.append(converter.serialize(value)) + + if not merge_next: + cl = ContentLine(name=self.ics_name, params=current_params) + cl.value_list = current_values + output.append(cl) + current_params = None + current_values = [] + + if merge_next: + raise ValueError("last value in value list may not have merge_next set") diff --git a/ics/event.py b/ics/event.py index b54f70e5..3d46fbb7 100644 --- a/ics/event.py +++ b/ics/event.py @@ -1,15 +1,17 @@ from datetime import datetime, timedelta -from typing import Any, Dict, List, NamedTuple, Optional, Set, Tuple, Union, overload +from typing import Any, List, Optional, Tuple import attr from attr.converters import optional as c_optional from attr.validators import in_, instance_of, optional as v_optional -from ics.alarm.base import BaseAlarm +from ics.alarm import BaseAlarm from ics.attendee import Attendee, Organizer from ics.component import Component -from ics.parsers.event_parser import EventParser -from ics.serializers.event_serializer import EventSerializer +from ics.converter.base import ics_attr_meta +from ics.converter.component import ComponentMeta +from ics.converter.timespan import TimespanConverter +from ics.geo import Geo, make_geo from ics.timespan import EventTimespan, Timespan from ics.types import DatetimeLike, EventOrTimespan, EventOrTimespanOrInstant, TimedeltaLike, get_timespan_if_calendar_entry from ics.utils import check_is_instance, ensure_datetime, ensure_timedelta, now_in_utc, uid_gen, validate_not_none @@ -17,34 +19,10 @@ STATUS_VALUES = (None, 'TENTATIVE', 'CONFIRMED', 'CANCELLED') -class Geo(NamedTuple): - latitude: float - longitude: float - - -@overload -def make_geo(value: None) -> None: - ... - - -@overload -def make_geo(value: Union[Dict[str, float], Tuple[float, float]]) -> "Geo": - ... - - -def make_geo(value): - if isinstance(value, dict): - return Geo(**value) - elif isinstance(value, tuple): - return Geo(*value) - else: - return None - - -@attr.s(repr=False, eq=True, order=False) +@attr.s(eq=True, order=False) class CalendarEntryAttrs(Component): - _timespan: Timespan = attr.ib(validator=instance_of(Timespan)) - name: Optional[str] = attr.ib(default=None) + _timespan: Timespan = attr.ib(validator=instance_of(Timespan), metadata=ics_attr_meta(converter=TimespanConverter)) + name: Optional[str] = attr.ib(default=None) # TODO name -> summary uid: str = attr.ib(factory=uid_gen) description: Optional[str] = attr.ib(default=None) @@ -225,7 +203,7 @@ def is_included_in(self, second: EventOrTimespan) -> bool: return self._timespan.is_included_in(get_timespan_if_calendar_entry(second)) -@attr.s(repr=False, eq=True, order=False) # order methods are provided by CalendarEntryAttrs +@attr.s(eq=True, order=False) # order methods are provided by CalendarEntryAttrs class EventAttrs(CalendarEntryAttrs): classification: Optional[str] = attr.ib(default=None, validator=v_optional(instance_of(str))) @@ -234,7 +212,7 @@ class EventAttrs(CalendarEntryAttrs): geo: Optional[Geo] = attr.ib(default=None, converter=make_geo) # type: ignore attendees: List[Attendee] = attr.ib(factory=list, converter=list) - categories: Set[str] = attr.ib(factory=set, converter=set) + categories: List[str] = attr.ib(factory=list, converter=list) def add_attendee(self, attendee: Attendee): """ Add an attendee to the attendees set """ @@ -256,10 +234,7 @@ class Event(EventAttrs): _timespan: EventTimespan = attr.ib(validator=instance_of(EventTimespan)) - class Meta: - name = "VEVENT" - parser = EventParser - serializer = EventSerializer + Meta = ComponentMeta("VEVENT") def __init__( self, diff --git a/ics/geo.py b/ics/geo.py new file mode 100644 index 00000000..64040fce --- /dev/null +++ b/ics/geo.py @@ -0,0 +1,26 @@ +from typing import Dict, NamedTuple, Tuple, Union, overload + + +class Geo(NamedTuple): + latitude: float + longitude: float + # TODO also store params like comment? + + +@overload +def make_geo(value: None) -> None: + ... + + +@overload +def make_geo(value: Union[Dict[str, float], Tuple[float, float]]) -> "Geo": + ... + + +def make_geo(value): + if isinstance(value, dict): + return Geo(**value) + elif isinstance(value, tuple): + return Geo(*value) + else: + return None diff --git a/ics/grammar/__init__.py b/ics/grammar/__init__.py index e69de29b..95803d5f 100644 --- a/ics/grammar/__init__.py +++ b/ics/grammar/__init__.py @@ -0,0 +1,219 @@ +import collections +import re +from pathlib import Path +from typing import Dict, List + +import attr +import tatsu +from tatsu.exceptions import FailedToken + +from ics.types import ContainerItem, RuntimeAttrValidation + +grammar_path = Path(__file__).parent.joinpath('contentline.ebnf') + +with open(grammar_path) as fd: + GRAMMAR = tatsu.compile(fd.read()) + + +class ParseError(Exception): + pass + + +@attr.s +class ContentLine(RuntimeAttrValidation): + """ + Represents one property line. + + For example: + + ``FOO;BAR=1:YOLO`` is represented by + + ``ContentLine('FOO', {'BAR': ['1']}, 'YOLO'))`` + """ + + name: str = attr.ib(converter=str.upper) # type: ignore + params: Dict[str, List[str]] = attr.ib(factory=dict) + value: str = attr.ib(default="") + + # TODO ensure (parameter) value escaping and name normalization + + def serialize(self): + params_str = '' + for pname in self.params: + params_str += ';{}={}'.format(pname, ','.join(self.params[pname])) + return "{}{}:{}".format(self.name, params_str, self.value) + + def __getitem__(self, item): + return self.params[item] + + def __setitem__(self, item, *values): + self.params[item] = list(values) + + @property + def value_list(self) -> List[str]: + return re.split("(?".format( - self.name, - len(self.params), - "s" if len(self.params) > 1 else "", - self.value, - ) - - def __getitem__(self, item): - return self.params[item] - - def __setitem__(self, item, *values): - self.params[item] = list(values) - - @classmethod - def parse(cls, line): - """Parse a single iCalendar-formatted line into a ContentLine""" - if "\n" in line or "\r" in line: - raise ValueError("ContentLine can only contain escaped newlines") - try: - ast = GRAMMAR.parse(line) - except FailedToken: - raise ParseError() - else: - return cls.interpret_ast(ast) - - @classmethod - def interpret_ast(cls, ast): - name = ''.join(ast['name']) - value = ''.join(ast['value']) - params = {} - for param_ast in ast.get('params', []): - param_name = ''.join(param_ast["name"]) - param_values = [''.join(x) for x in param_ast["values_"]] - params[param_name] = param_values - return cls(name, params, value) - - def clone(self): - """Makes a copy of itself""" - return attr.evolve(self) - - -class Container(List[ContainerItem]): - """Represents an iCalendar object. - Contains a list of ContentLines or Containers. - - Args: - - name: the name of the object (VCALENDAR, VEVENT etc.) - items: Containers or ContentLines - """ - - def __init__(self, name: str, *items: ContainerItem): - self.check_items(*items) - super(Container, self).__init__(items) - self.name = name - - def __str__(self): - name = self.name - ret = ['BEGIN:' + name] - for line in self: - ret.append(str(line)) - ret.append('END:' + name) - return "\r\n".join(ret) - - def __repr__(self): - return "" \ - .format(self.name, len(self), "s" if len(self) > 1 else "") - - @classmethod - def parse(cls, name, tokenized_lines): - items = [] - for line in tokenized_lines: - if line.name == 'BEGIN': - items.append(cls.parse(line.value, tokenized_lines)) - elif line.name == 'END': - if line.value != name: - raise ParseError( - "Expected END:{}, got END:{}".format(name, line.value)) - break - else: - items.append(line) - return cls(name, *items) - - def clone(self): - """Makes a copy of itself""" - return self.__class__(self.name, *self) - - def check_items(self, *items): - from ics.utils import check_is_instance - if len(items) == 1: - check_is_instance("item", items[0], (ContentLine, Container)) - else: - for nr, item in enumerate(items): - check_is_instance("item %s" % nr, item, (ContentLine, Container)) - - def __setitem__(self, index, value): - self.check_items(value) - super(Container, self).__setitem__(index, value) - - def insert(self, index, value): - self.check_items(value) - super(Container, self).insert(index, value) - - def append(self, value): - self.check_items(value) - super(Container, self).append(value) - - def extend(self, values): - self.check_items(*values) - super(Container, self).extend(values) - - def __add__(self, values): - container = type(self)(self.name) - container.extend(self) - container.extend(values) - return container - - def __iadd__(self, values): - self.extend(values) - return self - - -def unfold_lines(physical_lines): - if not isinstance(physical_lines, collections.abc.Iterable): - raise ParseError('Parameter `physical_lines` must be an iterable') - current_line = '' - for line in physical_lines: - if len(line.strip()) == 0: - continue - elif not current_line: - current_line = line.strip('\r') - elif line[0] in (' ', '\t'): - current_line += line[1:].strip('\r') - else: - yield current_line - current_line = line.strip('\r') - if current_line: - yield current_line - - -def tokenize_line(unfolded_lines): - for line in unfolded_lines: - yield ContentLine.parse(line) - - -def parse(tokenized_lines): - # tokenized_lines must be an iterator, so that Container.parse can consume/steal lines - tokenized_lines = iter(tokenized_lines) - res = [] - for line in tokenized_lines: - if line.name == 'BEGIN': - res.append(Container.parse(line.value, tokenized_lines)) - else: - res.append(line) - return res - - -def lines_to_container(lines): - return parse(tokenize_line(unfold_lines(lines))) - - -def string_to_container(txt): - return lines_to_container(txt.splitlines()) - - -def calendar_string_to_containers(string): - if not isinstance(string, str): - raise TypeError("Expecting a string") - return string_to_container(string) diff --git a/ics/icalendar.py b/ics/icalendar.py index 1e2afeee..465074ff 100644 --- a/ics/icalendar.py +++ b/ics/icalendar.py @@ -1,13 +1,13 @@ -from typing import Dict, Iterable, List, Optional, Union +from datetime import tzinfo +from typing import ClassVar, Iterable, List, Optional, Union import attr from attr.validators import instance_of from ics.component import Component +from ics.converter.component import ComponentMeta from ics.event import Event -from ics.grammar.parse import Container, calendar_string_to_containers -from ics.parsers.icalendar_parser import CalendarParser -from ics.serializers.icalendar_serializer import CalendarSerializer +from ics.grammar import Container, calendar_string_to_containers from ics.timeline import Timeline from ics.todo import Todo @@ -19,12 +19,7 @@ class CalendarAttrs(Component): scale: Optional[str] = attr.ib(default=None) method: Optional[str] = attr.ib(default=None) - version_params: Dict[str, List[str]] = attr.ib(factory=dict) - prodid_params: Dict[str, List[str]] = attr.ib(factory=dict) - scale_params: Dict[str, List[str]] = attr.ib(factory=dict) - method_params: Dict[str, List[str]] = attr.ib(factory=dict) - - _timezones: Dict = attr.ib(factory=dict, init=False, repr=False, eq=False, order=False, hash=False) + _timezones: List[tzinfo] = attr.ib(factory=list, converter=list) # , init=False, repr=False, eq=False, order=False, hash=False) events: List[Event] = attr.ib(factory=list, converter=list) todos: List[Todo] = attr.ib(factory=list, converter=list) @@ -41,13 +36,9 @@ class Calendar(CalendarAttrs): """ - class Meta: - name = 'VCALENDAR' - parser = CalendarParser - serializer = CalendarSerializer - - DEFAULT_VERSION = "2.0" - DEFAULT_PRODID = "ics.py - http://git.io/lLljaA" + Meta = ComponentMeta("VCALENDAR") + DEFAULT_VERSION: ClassVar[str] = "2.0" + DEFAULT_PRODID: ClassVar[str] = "ics.py - http://git.io/lLljaA" def __init__( self, @@ -69,21 +60,21 @@ def __init__( events = tuple() if todos is None: todos = tuple() - kwargs.setdefault("version", self.Meta.DEFAULT_VERSION) - kwargs.setdefault("prodid", creator if creator is not None else self.Meta.DEFAULT_PRODID) + kwargs.setdefault("version", self.DEFAULT_VERSION) + kwargs.setdefault("prodid", creator if creator is not None else self.DEFAULT_PRODID) super(Calendar, self).__init__(events=events, todos=todos, **kwargs) # type: ignore self.timeline = Timeline(self, None) if imports is not None: if isinstance(imports, Container): - self._populate(imports) + self.Meta.populate_instance(self, imports) # type:ignore else: containers = calendar_string_to_containers(imports) if len(containers) != 1: raise NotImplementedError( 'Multiple calendars in one file are not supported by this method. Use ics.Calendar.parse_multiple()') - self._populate(containers[0]) # Use first calendar + self.Meta.populate_instance(self, containers[0]) # type:ignore @property def creator(self) -> str: @@ -102,13 +93,6 @@ def parse_multiple(cls, string): containers = calendar_string_to_containers(string) return [cls(imports=c) for c in containers] - def __repr__(self) -> str: - return "" \ - .format(len(self.events), - "s" if len(self.events) > 1 else "", - len(self.todos), - "s" if len(self.todos) > 1 else "") - def __iter__(self) -> Iterable[str]: """Returns: iterable: an iterable version of __str__, line per line diff --git a/ics/parsers/alarm_parser.py b/ics/parsers/alarm_parser.py deleted file mode 100644 index 16cc41d1..00000000 --- a/ics/parsers/alarm_parser.py +++ /dev/null @@ -1,70 +0,0 @@ -import warnings -from typing import TYPE_CHECKING, List - -from ics.attendee import Attendee -from ics.grammar.parse import ContentLine -from ics.parsers.parser import Parser, option -from ics.utils import parse_datetime, parse_duration, unescape_string - -if TYPE_CHECKING: - from ics.alarm import BaseAlarm, AudioAlarm, DisplayAlarm, EmailAlarm, CustomAlarm - - -class BaseAlarmParser(Parser): - @option(required=True) - def parse_trigger(alarm: "BaseAlarm", line: ContentLine): - if line.params.get("VALUE", [""])[0] == "DATE-TIME": - alarm.trigger = parse_datetime(line) - elif line.params.get("VALUE", ["DURATION"])[0] == "DURATION": - alarm.trigger = parse_duration(line.value) - else: - warnings.warn( - "ics.py encountered a TRIGGER of unknown type '%s'. It has been ignored." - % line.params["VALUE"][0] - ) - - def parse_duration(alarm: "BaseAlarm", line: ContentLine): - if line: - alarm.duration = parse_duration(line.value) - - def parse_repeat(alarm: "BaseAlarm", line: ContentLine): - if line: - alarm.repeat = int(line.value) - - -class CustomAlarmParser(BaseAlarmParser): - @option(required=True) - def parse_action(alarm: "CustomAlarm", line: ContentLine): - if line: - alarm._action = line.value - - -class AudioAlarmParser(BaseAlarmParser): - def parse_attach(alarm: "AudioAlarm", line: ContentLine): - if line: - alarm.sound = line - - -class DisplayAlarmParser(BaseAlarmParser): - @option(required=True) - def parse_description(alarm: "DisplayAlarm", line: ContentLine): - alarm.display_text = unescape_string(line.value) if line else None - - -class EmailAlarmParser(BaseAlarmParser): - @option(required=True) - def parse_description(alarm: "EmailAlarm", line: ContentLine): - alarm.body = unescape_string(line.value) if line else None - - @option(required=True) - def parse_summary(alarm: "EmailAlarm", line: ContentLine): - alarm.subject = unescape_string(line.value) if line else None - - @option(required=True, multiple=True) - def parse_attendee(alarm: "EmailAlarm", lines: List[ContentLine]): - for line in lines: - alarm.recipients.append(Attendee.parse(line)) - - -class NoneAlarmParser(BaseAlarmParser): - pass diff --git a/ics/parsers/attendee_parser.py b/ics/parsers/attendee_parser.py deleted file mode 100644 index 7a9b9ba8..00000000 --- a/ics/parsers/attendee_parser.py +++ /dev/null @@ -1,39 +0,0 @@ -from typing import TYPE_CHECKING - -from ics.parsers.parser import Parser -from ics.utils import unescape_string - -if TYPE_CHECKING: - from ics.attendee import Person, Attendee - - -class PersonParser(Parser): - def parse_cn(person: "Person", value): - if value: - person.common_name = unescape_string(value) - - def parse_dir(person: "Person", value): - if value: - person.dir = unescape_string(value) - - def parse_sent_by(person: "Person", value): - if value: - person.sent_by = unescape_string(value) - - -class AttendeeParser(PersonParser): - def parse_rsvp(attendee: "Attendee", value): - if value: - attendee.rsvp = bool(value) - - def parse_role(attendee: "Attendee", value): - if value: - attendee.role = unescape_string(value) - - def parse_partstat(attendee: "Attendee", value): - if value: - attendee.partstat = unescape_string(value) - - def parse_cutype(attendee: "Attendee", value): - if value: - attendee.cutype = unescape_string(value) diff --git a/ics/parsers/event_parser.py b/ics/parsers/event_parser.py deleted file mode 100644 index af75cf68..00000000 --- a/ics/parsers/event_parser.py +++ /dev/null @@ -1,104 +0,0 @@ -import re -from typing import List, TYPE_CHECKING - -from ics.alarm.utils import get_type_from_container -from ics.attendee import Attendee, Organizer -from ics.grammar.parse import ContentLine -from ics.parsers.parser import Parser, option -from ics.utils import iso_precision, parse_datetime, parse_duration, unescape_string - -if TYPE_CHECKING: - from ics.event import Event - - -class EventParser(Parser): - def parse_dtstamp(event: "Event", line: ContentLine): - if line: - # get the dict of vtimezones passed to the classmethod - tz_dict = event._classmethod_kwargs["tz"] - event.dtstamp = parse_datetime(line, tz_dict) - - def parse_created(event: "Event", line: ContentLine): - if line: - tz_dict = event._classmethod_kwargs["tz"] - event.created = parse_datetime(line, tz_dict) - - def parse_last_modified(event: "Event", line: ContentLine): - if line: - tz_dict = event._classmethod_kwargs["tz"] - event.last_modified = parse_datetime(line, tz_dict) - - def parse1_dtstart(event: "Event", line: ContentLine): - if line: - # get the dict of vtimezones passed to the classmethod - tz_dict = event._classmethod_kwargs["tz"] - event._timespan = event._timespan.replace( - begin_time=parse_datetime(line, tz_dict), - precision=iso_precision(line.value) - ) - - def parse2_duration(event: "Event", line: ContentLine): - if line: - event._timespan = event._timespan.replace( - duration=parse_duration(line.value) - ) - - def parse3_dtend(event: "Event", line: ContentLine): - if line: - tz_dict = event._classmethod_kwargs["tz"] - event._timespan = event._timespan.replace( - end_time=parse_datetime(line, tz_dict) - ) - - def parse_summary(event: "Event", line: ContentLine): - event.name = unescape_string(line.value) if line else None - - def parse_organizer(event: "Event", line: ContentLine): - event.organizer = Organizer.parse(line) if line else None - - @option(multiple=True) - def parse_attendee(event: "Event", lines: List[ContentLine]): - for line in lines: - event.attendees.append(Attendee.parse(line)) - - def parse_description(event: "Event", line: ContentLine): - event.description = unescape_string(line.value) if line else None - - def parse_location(event: "Event", line: ContentLine): - event.location = unescape_string(line.value) if line else None - - def parse_geo(event: "Event", line: ContentLine): - if line: - latitude, _, longitude = unescape_string(line.value).partition(";") - event.geo = float(latitude), float(longitude) - - def parse_url(event: "Event", line: ContentLine): - event.url = unescape_string(line.value) if line else None - - def parse_transp(event: "Event", line: ContentLine): - if line and line.value in ["TRANSPARENT", "OPAQUE"]: - event.transparent = line.value == "TRANSPARENT" - - # TODO : make uid required ? - def parse_uid(event: "Event", line: ContentLine): - if line: - event.uid = line.value - - @option(multiple=True) - def parse_valarm(event, lines: List[ContentLine]): - event.alarms = [get_type_from_container(x)._from_container(x) for x in lines] - - def parse_status(event: "Event", line: ContentLine): - if line: - event.status = line.value - - def parse_class(event: "Event", line: ContentLine): - if line: - event.classification = line.value - - def parse_categories(event: "Event", line: ContentLine): - event.categories = set() - if line: - # In the regular expression: Only match unquoted commas. - for cat in re.split("(? Dict[str, Tuple[Callable, ParserOption]]: - methods = sorted( - (method_name, getattr(cls, method_name)) - for method_name in dir(cls) - if callable(getattr(cls, method_name)) - ) - parsers = [ - (method_name, method_callable) - for (method_name, method_callable) in methods - if re.match("^parse[0-9]*_", method_name) - ] - return OrderedDict( - ( - method_name.split("_", 1)[1].upper().replace("_", "-"), ( - method_callable, - getattr(method_callable, "options", ParserOption()), - ) - ) - for (method_name, method_callable) in parsers - ) - - -def option( - required: bool = False, - multiple: bool = False, - default: Optional[List[ContentLine]] = None, -) -> Callable: - def decorator(fn): - fn.options = ParserOption(required, multiple, default) - return fn - - return decorator diff --git a/ics/parsers/todo_parser.py b/ics/parsers/todo_parser.py deleted file mode 100644 index 38cf93d4..00000000 --- a/ics/parsers/todo_parser.py +++ /dev/null @@ -1,87 +0,0 @@ -from typing import List, TYPE_CHECKING - -from ics.alarm.utils import get_type_from_container -from ics.grammar.parse import ContentLine -from ics.parsers.parser import Parser, option -from ics.utils import parse_datetime, parse_duration, unescape_string - -if TYPE_CHECKING: - from ics.todo import Todo - - -class TodoParser(Parser): - @option(required=True) - def parse_dtstamp(todo: "Todo", line: ContentLine): - if line: - # get the dict of vtimezones passed to the classmethod - tz_dict = todo._classmethod_kwargs["tz"] - todo.dtstamp = parse_datetime(line, tz_dict) - - def parse_last_modified(todo: "Todo", line: ContentLine): - if line: - tz_dict = todo._classmethod_kwargs["tz"] - todo.last_modified = parse_datetime(line, tz_dict) - - @option(required=True) - def parse_uid(todo: "Todo", line: ContentLine): - if line: - todo.uid = line.value - - def parse_completed(todo: "Todo", line: ContentLine): - if line: - # get the dict of vtimezones passed to the classmethod - tz_dict = todo._classmethod_kwargs["tz"] - todo.completed = parse_datetime(line, tz_dict) - - def parse_created(todo: "Todo", line: ContentLine): - if line: - # get the dict of vtimezones passed to the classmethod - tz_dict = todo._classmethod_kwargs["tz"] - todo.created = parse_datetime(line, tz_dict) - - def parse_description(todo: "Todo", line: ContentLine): - todo.description = unescape_string(line.value) if line else None - - def parse_location(todo: "Todo", line: ContentLine): - todo.location = unescape_string(line.value) if line else None - - def parse_percent_complete(todo: "Todo", line: ContentLine): - todo.percent = int(line.value) if line else None - - def parse_priority(todo: "Todo", line: ContentLine): - todo.priority = int(line.value) if line else None - - def parse_summary(todo: "Todo", line: ContentLine): - todo.name = unescape_string(line.value) if line else None - - def parse_url(todo: "Todo", line: ContentLine): - todo.url = unescape_string(line.value) if line else None - - def parse_dtstart(todo: "Todo", line: ContentLine): - if line: - # get the dict of vtimezones passed to the classmethod - tz_dict = todo._classmethod_kwargs["tz"] - todo._timespan = todo._timespan.replace( - begin_time=parse_datetime(line, tz_dict) - ) - - def parse_duration(todo: "Todo", line: ContentLine): - if line: - todo._timespan = todo._timespan.replace( - duration=parse_duration(line.value) - ) - - def parse_due(todo: "Todo", line: ContentLine): - if line: - tz_dict = todo._classmethod_kwargs["tz"] - todo._timespan = todo._timespan.replace( - end_time=parse_datetime(line, tz_dict) - ) - - @option(multiple=True) - def parse_valarm(todo: "Todo", lines: List[ContentLine]): - todo.alarms = [get_type_from_container(x)._from_container(x) for x in lines] - - def parse_status(todo: "Todo", line: ContentLine): - if line: - todo.status = line.value diff --git a/ics/serializers/alarm_serializer.py b/ics/serializers/alarm_serializer.py deleted file mode 100644 index effe83ce..00000000 --- a/ics/serializers/alarm_serializer.py +++ /dev/null @@ -1,68 +0,0 @@ -from datetime import timedelta - -from ics.grammar.parse import ContentLine -from ics.serializers.serializer import Serializer -from ics.utils import escape_string, serialize_datetime_to_contentline, serialize_duration - - -class BaseAlarmSerializer(Serializer): - def serialize_trigger(alarm, container): - if not alarm.trigger: - raise ValueError("Alarm must have a trigger") - - if isinstance(alarm.trigger, timedelta): - representation = serialize_duration(alarm.trigger) - container.append(ContentLine("TRIGGER", value=representation)) - else: - cl = serialize_datetime_to_contentline("TRIGGER", alarm.trigger) - cl.params["VALUE"] = ["DATE-TIME"] - container.append(cl) - - def serialize_duration(alarm, container): - if alarm.duration: - representation = serialize_duration(alarm.duration) - container.append(ContentLine("DURATION", value=representation)) - - def serialize_repeat(alarm, container): - if alarm.repeat: - container.append(ContentLine("REPEAT", value=alarm.repeat)) - - def serialize_action(alarm, container): - container.append(ContentLine("ACTION", value=alarm.action)) - - -class CustomAlarmSerializer(BaseAlarmSerializer): - pass - - -class AudioAlarmSerializer(BaseAlarmSerializer): - def serialize_attach(alarm, container): - if alarm.sound: - container.append(alarm.sound) - - -class DisplayAlarmSerializer(BaseAlarmSerializer): - def serialize_description(alarm, container): - container.append( - ContentLine("DESCRIPTION", value=escape_string(alarm.display_text or "")) - ) - - -class EmailAlarmSerializer(BaseAlarmSerializer): - def serialize_body(alarm, container): - container.append( - ContentLine("DESCRIPTION", value=escape_string(alarm.body or "")) - ) - - def serialize_subject(alarm, container): - container.append( - ContentLine("SUMMARY", value=escape_string(alarm.subject or "")) - ) - - def serialize_recipients(alarm, container): - for attendee in alarm.recipients: - container.append(attendee.serialize()) - - -class NoneAlarmSerializer(BaseAlarmSerializer): - pass diff --git a/ics/serializers/attendee_serializer.py b/ics/serializers/attendee_serializer.py deleted file mode 100644 index ddd97df7..00000000 --- a/ics/serializers/attendee_serializer.py +++ /dev/null @@ -1,40 +0,0 @@ -from typing import TYPE_CHECKING - -from ics.grammar.parse import ContentLine -from ics.serializers.serializer import Serializer -from ics.utils import escape_string - -if TYPE_CHECKING: - from ics.attendee import Person, Attendee - - -class PersonSerializer(Serializer): - def serialize_cn(person: "Person", line: ContentLine): - if person.common_name: - line.params["CN"] = [escape_string(person.common_name)] - - def serialize_dir(person: "Person", line: ContentLine): - if person.dir: - line.params["DIR"] = [escape_string(person.dir)] - - def serialize_sent_by(person: "Person", line: ContentLine): - if person.sent_by: - line.params["SENT-BY"] = [escape_string(person.sent_by)] - - -class AttendeeSerializer(PersonSerializer): - def serialize_rsvp(attendee: "Attendee", line: ContentLine): - if attendee.rsvp is not None: - line.params["RSVP"] = [str(attendee.rsvp).upper()] - - def serialize_role(attendee: "Attendee", line: ContentLine): - if attendee.role: - line.params["ROLE"] = [escape_string(attendee.role)] - - def serialize_partstat(attendee: "Attendee", line: ContentLine): - if attendee.partstat: - line.params["PARTSTAT"] = [escape_string(attendee.partstat)] - - def serialize_cutype(attendee: "Attendee", line: ContentLine): - if attendee.cutype: - line.params["CUTYPE"] = [escape_string(attendee.cutype)] diff --git a/ics/serializers/event_serializer.py b/ics/serializers/event_serializer.py deleted file mode 100644 index 86460554..00000000 --- a/ics/serializers/event_serializer.py +++ /dev/null @@ -1,131 +0,0 @@ -from typing import TYPE_CHECKING - -from ics.attendee import Attendee, Organizer -from ics.grammar.parse import ContentLine -from ics.serializers.serializer import Serializer -from ics.utils import (escape_string, serialize_date, serialize_datetime_to_contentline, serialize_duration, uid_gen) - -if TYPE_CHECKING: - from ics.event import Event - from ics.grammar.parse import Container - - -class EventSerializer(Serializer): - def serialize_dtstamp(event: "Event", container: "Container"): - container.append(serialize_datetime_to_contentline("DTSTAMP", event.dtstamp)) - - def serialize_created(event: "Event", container: "Container"): - if event.created: - container.append(serialize_datetime_to_contentline("CREATED", event.created)) - - def serialize_last_modified(event: "Event", container: "Container"): - if event.last_modified: - container.append(serialize_datetime_to_contentline("LAST-MODIFIED", event.last_modified)) - - def serialize_start(event: "Event", container: "Container"): - if event.begin: - if not event.all_day: - container.append(serialize_datetime_to_contentline("DTSTART", event.begin)) - else: - container.append( - ContentLine( - "DTSTART", - params={"VALUE": ["DATE"]}, - value=serialize_date(event.begin), - ) - ) - - def serialize_end(event: "Event", container: "Container"): - if event.end_representation == "end": - end = event.end - assert end is not None - if not event.all_day: - container.append(serialize_datetime_to_contentline("DTEND", end)) - else: - container.append( - ContentLine( - "DTSTART", - params={"VALUE": ["DATE"]}, - value=serialize_date(end), - ) - ) - - def serialize_duration(event: "Event", container: "Container"): - if event.end_representation == "duration": - duration = event.duration - assert duration is not None - container.append(ContentLine("DURATION", value=serialize_duration(duration))) - - def serialize_summary(event: "Event", container: "Container"): - if event.name: - container.append(ContentLine("SUMMARY", value=escape_string(event.name))) - - def serialize_organizer(event: "Event", container: "Container"): - if event.organizer: - organizer = event.organizer - if isinstance(organizer, str): - organizer = Organizer(organizer) - container.append(organizer.serialize()) - - def serialize_attendee(event: "Event", container: "Container"): - for attendee in event.attendees: - if isinstance(attendee, str): - attendee = Attendee(attendee) - container.append(attendee.serialize()) - - def serialize_description(event: "Event", container: "Container"): - if event.description: - container.append( - ContentLine("DESCRIPTION", value=escape_string(event.description)) - ) - - def serialize_location(event: "Event", container: "Container"): - if event.location: - container.append( - ContentLine("LOCATION", value=escape_string(event.location)) - ) - - def serialize_geo(event: "Event", container: "Container"): - if event.geo: - container.append(ContentLine("GEO", value="%f;%f" % event.geo)) - - def serialize_url(event: "Event", container: "Container"): - if event.url: - container.append(ContentLine("URL", value=escape_string(event.url))) - - def serialize_transparent(event: "Event", container: "Container"): - if event.transparent is None: - return - if event.transparent: - container.append(ContentLine("TRANSP", value=escape_string("TRANSPARENT"))) - else: - container.append(ContentLine("TRANSP", value=escape_string("OPAQUE"))) - - def serialize_uid(event: "Event", container: "Container"): - if event.uid: - uid = event.uid - else: - uid = uid_gen() - - container.append(ContentLine("UID", value=uid)) - - def serialize_alarm(event: "Event", container: "Container"): - for alarm in event.alarms: - container.append(alarm.serialize()) - - def serialize_status(event: "Event", container: "Container"): - if event.status: - container.append(ContentLine("STATUS", value=event.status)) - - def serialize_class(event: "Event", container: "Container"): - if event.classification: - container.append(ContentLine("CLASS", value=event.classification)) - - def serialize_categories(event: "Event", container: "Container"): - if event.categories: - container.append( - ContentLine( - "CATEGORIES", - value=",".join([escape_string(s) for s in event.categories]), - ) - ) diff --git a/ics/serializers/icalendar_serializer.py b/ics/serializers/icalendar_serializer.py deleted file mode 100644 index 70177de1..00000000 --- a/ics/serializers/icalendar_serializer.py +++ /dev/null @@ -1,31 +0,0 @@ -from ics.grammar.parse import ContentLine -from ics.serializers.serializer import Serializer - - -class CalendarSerializer(Serializer): - def serialize_0version(calendar, container): # 0version will be sorted first - container.append(ContentLine("VERSION", value="2.0")) - - def serialize_1prodid(calendar, container): # 1prodid will be sorted second - if calendar.prodid: - prodid = calendar.prodid - else: - prodid = "ics.py - http://git.io/lLljaA" - - container.append(ContentLine("PRODID", value=prodid)) - - def serialize_calscale(calendar, container): - if calendar.scale: - container.append(ContentLine("CALSCALE", value=calendar.scale.upper())) - - def serialize_method(calendar, container): - if calendar.method: - container.append(ContentLine("METHOD", value=calendar.method.upper())) - - def serialize_event(calendar, container): - for event in calendar.events: - container.append(event.serialize()) - - def serialize_todo(calendar, container): - for todo in calendar.todos: - container.append(todo.serialize()) diff --git a/ics/serializers/serializer.py b/ics/serializers/serializer.py deleted file mode 100644 index aa9ae43b..00000000 --- a/ics/serializers/serializer.py +++ /dev/null @@ -1,16 +0,0 @@ -from typing import Callable, List - - -class Serializer: - @classmethod - def get_serializers(cls) -> List[Callable]: - methods = [ - (method_name, getattr(cls, method_name)) - for method_name in dir(cls) - if callable(getattr(cls, method_name)) - ] - return sorted([ - method_callable - for (method_name, method_callable) in methods - if method_name.startswith("serialize_") - ], key=lambda x: x.__name__) diff --git a/ics/serializers/todo_serializer.py b/ics/serializers/todo_serializer.py deleted file mode 100644 index 74e3a440..00000000 --- a/ics/serializers/todo_serializer.py +++ /dev/null @@ -1,87 +0,0 @@ -from typing import TYPE_CHECKING - -from ics.grammar.parse import Container, ContentLine -from ics.serializers.serializer import Serializer -from ics.utils import (escape_string, serialize_datetime_to_contentline, serialize_duration, uid_gen) - -if TYPE_CHECKING: - from ics.todo import Todo - - -class TodoSerializer(Serializer): - def serialize_dtstamp(todo: "Todo", container: "Container"): - container.append(serialize_datetime_to_contentline("DTSTAMP", todo.dtstamp)) - - def serialize_created(todo: "Todo", container: "Container"): - if todo.created: - container.append(serialize_datetime_to_contentline("CREATED", todo.created)) - - def serialize_last_modified(todo: "Todo", container: "Container"): - if todo.last_modified: - container.append(serialize_datetime_to_contentline("LAST-MODIFIED", todo.last_modified)) - - def serialize_uid(todo: "Todo", container: Container): - if todo.uid: - uid = todo.uid - else: - uid = uid_gen() - - container.append(ContentLine("UID", value=uid)) - - def serialize_completed(todo: "Todo", container: Container): - if todo.completed: - container.append( - serialize_datetime_to_contentline("COMPLETED", todo.completed) - ) - - def serialize_description(todo: "Todo", container: Container): - if todo.description: - container.append( - ContentLine("DESCRIPTION", value=escape_string(todo.description)) - ) - - def serialize_location(todo: "Todo", container: Container): - if todo.location: - container.append( - ContentLine("LOCATION", value=escape_string(todo.location)) - ) - - def serialize_percent(todo: "Todo", container: Container): - if todo.percent is not None: - container.append(ContentLine("PERCENT-COMPLETE", value=str(todo.percent))) - - def serialize_priority(todo: "Todo", container: Container): - if todo.priority is not None: - container.append(ContentLine("PRIORITY", value=str(todo.priority))) - - def serialize_summary(todo: "Todo", container: Container): - if todo.name: - container.append(ContentLine("SUMMARY", value=escape_string(todo.name))) - - def serialize_url(todo: "Todo", container: Container): - if todo.url: - container.append(ContentLine("URL", value=escape_string(todo.url))) - - def serialize_start(todo: "Todo", container: Container): - if todo.begin: - container.append(serialize_datetime_to_contentline("DTSTART", todo.begin)) - - def serialize_due(todo: "Todo", container: Container): - if todo.due_representation == "end": - due = todo.due - assert due is not None - container.append(serialize_datetime_to_contentline("DUE", due)) - - def serialize_duration(todo: "Todo", container: Container): - if todo.due_representation == "duration": - duration = todo.duration - assert duration is not None - container.append(ContentLine("DURATION", value=serialize_duration(duration))) - - def serialize_alarm(todo: "Todo", container: Container): - for alarm in todo.alarms: - container.append(alarm.serialize()) - - def serialize_status(todo: "Todo", container: Container): - if todo.status: - container.append(ContentLine("STATUS", value=todo.status)) diff --git a/ics/timespan.py b/ics/timespan.py index 749488ab..a4a21778 100644 --- a/ics/timespan.py +++ b/ics/timespan.py @@ -6,7 +6,7 @@ from dateutil.tz import tzlocal from ics.types import DatetimeLike -from ics.utils import TIMEDELTA_CACHE, ceil_datetime_to_midnight, ensure_datetime, floor_datetime_to_midnight, timedelta_nearly_zero +from ics.utils import TIMEDELTA_CACHE, TIMEDELTA_DAY, TIMEDELTA_ZERO, ceil_datetime_to_midnight, ensure_datetime, floor_datetime_to_midnight, timedelta_nearly_zero @attr.s @@ -130,7 +130,7 @@ def validate_timeprecision(value, name): validate_timeprecision(self.end_time, self._end_name()) if self.begin_time > self.end_time: raise ValueError("begin time must be before " + self._end_name() + " time") - if self.precision == "day" and self.end_time < (self.begin_time + TIMEDELTA_CACHE["day"]): + if self.precision == "day" and self.end_time < (self.begin_time + TIMEDELTA_DAY): raise ValueError("all-day timespan duration must be at least one day") if self.duration is not None: raise ValueError("can't set duration together with " + self._end_name() + " time") @@ -144,9 +144,9 @@ def validate_timeprecision(value, name): (self.get_effective_duration(), self.precision)) if self.duration is not None: - if self.duration < TIMEDELTA_CACHE[0]: + if self.duration < TIMEDELTA_ZERO: raise ValueError("timespan duration must be positive") - if self.precision == "day" and self.duration < TIMEDELTA_CACHE["day"]: + if self.precision == "day" and self.duration < TIMEDELTA_DAY: raise ValueError("all-day timespan duration must be at least one day") if not timedelta_nearly_zero(self.duration % TIMEDELTA_CACHE[self.precision]): raise ValueError("duration value %s has higher precision than set precision %s" % @@ -219,7 +219,7 @@ def make_all_day(self) -> "Timespan": if end is not None: end = ceil_datetime_to_midnight(end).replace(tzinfo=None) if end == begin: # we also add another day if the duration would be 0 otherwise - end = end + TIMEDELTA_CACHE["day"] + end = end + TIMEDELTA_DAY if self.get_end_representation() == "duration": assert end is not None @@ -418,9 +418,9 @@ def get_effective_duration(self) -> timedelta: elif self.end_time is not None and self.begin_time is not None: return self.end_time - self.begin_time elif self.is_all_day(): - return TIMEDELTA_CACHE["day"] + return TIMEDELTA_DAY else: - return TIMEDELTA_CACHE[0] + return TIMEDELTA_ZERO class TodoTimespan(Timespan): diff --git a/ics/todo.py b/ics/todo.py index 3eb94043..801211f9 100644 --- a/ics/todo.py +++ b/ics/todo.py @@ -10,9 +10,8 @@ import attr from attr.validators import in_, instance_of, optional as v_optional +from ics.converter.component import ComponentMeta from ics.event import CalendarEntryAttrs -from ics.parsers.todo_parser import TodoParser -from ics.serializers.todo_serializer import TodoSerializer from ics.timespan import TodoTimespan from ics.types import DatetimeLike, TimedeltaLike from ics.utils import ensure_datetime, ensure_timedelta @@ -34,7 +33,7 @@ def wrapper(*args, **kwargs): return wrapper -@attr.s(repr=False, eq=True, order=False) # order methods are provided by CalendarEntryAttrs +@attr.s(eq=True, order=False) # order methods are provided by CalendarEntryAttrs class TodoAttrs(CalendarEntryAttrs): percent: Optional[int] = attr.ib(default=None, validator=v_optional(in_(range(0, MAX_PERCENT + 1)))) priority: Optional[int] = attr.ib(default=None, validator=v_optional(in_(range(0, MAX_PRIORITY + 1)))) @@ -49,10 +48,7 @@ class Todo(TodoAttrs): """ _timespan: TodoTimespan = attr.ib(validator=instance_of(TodoTimespan)) - class Meta: - name = "VTODO" - parser = TodoParser - serializer = TodoSerializer + Meta = ComponentMeta("VTODO") def __init__( self, diff --git a/ics/tools.py b/ics/tools.py deleted file mode 100644 index d178de87..00000000 --- a/ics/tools.py +++ /dev/null @@ -1,26 +0,0 @@ -import re - - -def striphtml(data): - p = re.compile(r'<.*?>') - return p.sub('', data) - - -def validate(string): - import requests - payload = {'snip': string} - ret = requests.post('http://severinghaus.org/projects/icv/', data=payload) - if 'Sorry, your calendar could not be parsed.' in ret.text: - i = ret.text.index('
') - j = ret.text[i:].index('
') - usefull = ret.text[i:i + j] - usefull_clean = striphtml(usefull) - lines = usefull_clean.split('\n') - lines_clean = map(lambda x: x.strip(), lines) - lines_no_empty = filter(lambda x: x != '', lines_clean) - return '\n'.join(lines_no_empty) - - elif 'Congratulations; your calendar validated!' in ret.text: - return True - else: - return None diff --git a/ics/types.py b/ics/types.py index 4480fc9e..725fdcbd 100644 --- a/ics/types.py +++ b/ics/types.py @@ -11,12 +11,27 @@ # noinspection PyUnresolvedReferences from ics.timespan import Timespan # noinspection PyUnresolvedReferences - from ics.grammar.parse import ContentLine, Container + from ics.grammar import ContentLine, Container __all__ = [ - "ContainerItem", "ContainerList", "DatetimeLike", "OptionalDatetimeLike", "TimespanOrBegin", "EventOrTimespan", - "EventOrTimespanOrInstant", "TodoOrTimespan", "TodoOrTimespanOrInstant", "CalendarEntryOrTimespan", - "CalendarEntryOrTimespanOrInstant", "OptionalTZDict", "get_timespan_if_calendar_entry" + "ContainerItem", "ContainerList", + + "DatetimeLike", "OptionalDatetimeLike", + "TimedeltaLike", "OptionalTimedeltaLike", + + "TimespanOrBegin", + "EventOrTimespan", + "EventOrTimespanOrInstant", + "TodoOrTimespan", + "TodoOrTimespanOrInstant", + "CalendarEntryOrTimespan", + "CalendarEntryOrTimespanOrInstant", + + "OptionalTZDict", + + "get_timespan_if_calendar_entry", + + "RuntimeAttrValidation", ] ContainerItem = Union["ContentLine", "Container"] diff --git a/ics/utils.py b/ics/utils.py index 9f212ad1..0c09aef6 100644 --- a/ics/utils.py +++ b/ics/utils.py @@ -1,89 +1,25 @@ from datetime import date, datetime, time, timedelta, timezone -from typing import Generator, Optional, overload +from typing import Generator, overload from uuid import uuid4 -from dateutil.tz import UTC as dateutil_tzutc, gettz +from dateutil.tz import UTC as dateutil_tzutc -from ics.grammar.parse import Container, ContentLine, ParseError -from ics.types import ContainerList, DatetimeLike, OptionalTZDict, TimedeltaLike +from ics.types import DatetimeLike, TimedeltaLike datetime_tzutc = timezone.utc -midnight = time() -DATE_FORMATS = { - 6: "%Y%m", - 8: "%Y%m%d", - 11: "%Y%m%dT%H", - 13: "%Y%m%dT%H%M", - 15: "%Y%m%dT%H%M%S" -} + +MIDNIGHT = time() +TIMEDELTA_ZERO = timedelta() +TIMEDELTA_DAY = timedelta(days=1) +TIMEDELTA_SECOND = timedelta(seconds=1) TIMEDELTA_CACHE = { - 0: timedelta(), - "day": timedelta(days=1), - "second": timedelta(seconds=1) + 0: TIMEDELTA_ZERO, + "day": TIMEDELTA_DAY, + "second": TIMEDELTA_SECOND } MAX_TIMEDELTA_NEARLY_ZERO = timedelta(seconds=1) / 2 -def timedelta_nearly_zero(td: timedelta) -> bool: - return -MAX_TIMEDELTA_NEARLY_ZERO <= td <= MAX_TIMEDELTA_NEARLY_ZERO - - -@overload -def parse_datetime(time_container: None, available_tz: OptionalTZDict = None) -> None: ... - - -@overload -def parse_datetime(time_container: ContentLine, available_tz: OptionalTZDict = None) -> datetime: ... - - -def parse_datetime(time_container, available_tz=None): - if time_container is None: - return None - - tz_list = time_container.params.get('TZID') - param_tz: Optional[str] = tz_list[0] if tz_list else None - # if ('T' not in time_container.value) and 'DATE' in time_container.params.get('VALUE', []): - val = time_container.value - fixed_utc = (val[-1].upper() == 'Z') - - val = val.translate({ - ord("/"): "", - ord("-"): "", - ord("Z"): "", - ord("z"): ""}) - dt = datetime.strptime(val, DATE_FORMATS[len(val)]) - - if fixed_utc: - if param_tz: - raise ValueError("can't specify UTC via appended 'Z' and TZID param '%s'" % param_tz) - return dt.replace(tzinfo=dateutil_tzutc) - elif param_tz: - selected_tz = None - if available_tz: - selected_tz = available_tz.get(param_tz, None) - if selected_tz is None: - selected_tz = gettz(param_tz) # be lenient with missing vtimezone definitions - return dt.replace(tzinfo=selected_tz) - else: - return dt - - -@overload -def parse_date(time_container: None, available_tz: OptionalTZDict = None) -> None: ... - - -@overload -def parse_date(time_container: ContentLine, available_tz: OptionalTZDict = None) -> datetime: ... - - -def parse_date(time_container, available_tz=None): - dt = parse_datetime(time_container, available_tz) - if dt: - return ensure_datetime(dt.date()) - else: - return None - - @overload def ensure_datetime(value: None) -> None: ... @@ -98,7 +34,7 @@ def ensure_datetime(value): elif isinstance(value, datetime): return value elif isinstance(value, date): - return datetime.combine(value, midnight, tzinfo=None) + return datetime.combine(value, MIDNIGHT, tzinfo=None) elif isinstance(value, tuple): return datetime(*value) elif isinstance(value, dict): @@ -107,28 +43,6 @@ def ensure_datetime(value): raise ValueError("can't construct datetime from %s" % repr(value)) -def serialize_datetime(instant: datetime, is_utc: bool = False) -> str: - if is_utc: - return instant.strftime('%Y%m%dT%H%M%SZ') - else: - return instant.strftime('%Y%m%dT%H%M%S') - - -def serialize_datetime_to_contentline(name: str, instant: datetime, used_timezones: OptionalTZDict = None) -> ContentLine: - # ToDo keep track of used_timezones - if instant.tzinfo is not None: - tzname = instant.tzinfo.tzname(instant) - if tzname is None: - raise ValueError("timezone of instant '%s' is not None but has no name" % instant) - if is_utc(instant): - return ContentLine(name, value=serialize_datetime(instant, True)) - if used_timezones: - used_timezones[tzname] = instant.tzinfo - return ContentLine(name, params={'TZID': [tzname]}, value=serialize_datetime(instant, False)) - else: - return ContentLine(name, value=serialize_datetime(instant, False)) - - def now_in_utc() -> datetime: return datetime.now(tz=dateutil_tzutc) @@ -140,7 +54,7 @@ def is_utc(instant: datetime) -> bool: if tz in [dateutil_tzutc, datetime_tzutc]: return True offset = tz.utcoffset(instant) - if offset == TIMEDELTA_CACHE[0]: + if offset == TIMEDELTA_ZERO: return True # tzname = tz.tzname(instant) # if tzname and tzname.upper() == "UTC": @@ -148,20 +62,6 @@ def is_utc(instant: datetime) -> bool: return False -def serialize_date(instant: DatetimeLike) -> str: - if not isinstance(instant, date): - instant = ensure_datetime(instant).date() - return instant.strftime('%Y%m%d') - - -def iso_precision(string: str) -> str: - has_time = 'T' in string - if has_time: - return 'second' - else: - return 'day' - - @overload def ensure_timedelta(value: None) -> None: ... @@ -183,80 +83,13 @@ def ensure_timedelta(value): raise ValueError("can't construct timedelta from %s" % repr(value)) -def parse_duration(line: str) -> timedelta: - """ - Return a timedelta object from a string in the DURATION property format - """ - DAYS = {'D': 1, 'W': 7} - SECS = {'S': 1, 'M': 60, 'H': 3600} - - sign, i = 1, 0 - if line[i] in '-+': - if line[i] == '-': - sign = -1 - i += 1 - if line[i] != 'P': - raise ParseError("Error while parsing %s" % line) - i += 1 - days, secs = 0, 0 - while i < len(line): - if line[i] == 'T': - i += 1 - if i == len(line): - break - j = i - while line[j].isdigit(): - j += 1 - if i == j: - raise ParseError("Error while parsing %s" % line) - val = int(line[i:j]) - if line[j] in DAYS: - days += val * DAYS[line[j]] - DAYS.pop(line[j]) - elif line[j] in SECS: - secs += val * SECS[line[j]] - SECS.pop(line[j]) - else: - raise ParseError("Error while parsing %s" % line) - i = j + 1 - return timedelta(sign * days, sign * secs) - - -def serialize_duration(dt: timedelta) -> str: - """ - Return a string according to the DURATION property format - from a timedelta object - """ - ONE_DAY_IN_SECS = 3600 * 24 - total = abs(int(dt.total_seconds())) - days = total // ONE_DAY_IN_SECS - seconds = total % ONE_DAY_IN_SECS - - res = '' - if days: - res += str(days) + 'D' - if seconds: - res += 'T' - if seconds // 3600: - res += str(seconds // 3600) + 'H' - seconds %= 3600 - if seconds // 60: - res += str(seconds // 60) + 'M' - seconds %= 60 - if seconds: - res += str(seconds) + 'S' - - if not res: - res = 'T0S' - if dt.total_seconds() >= 0: - return 'P' + res - else: - return '-P%s' % res - - ############################################################################### # Rounding Utils +def timedelta_nearly_zero(td: timedelta) -> bool: + return -MAX_TIMEDELTA_NEARLY_ZERO <= td <= MAX_TIMEDELTA_NEARLY_ZERO + + @overload def floor_datetime_to_midnight(value: datetime) -> datetime: ... @@ -274,7 +107,7 @@ def floor_datetime_to_midnight(value): return None if isinstance(value, date) and not isinstance(value, datetime): return value - return datetime.combine(ensure_datetime(value).date(), midnight, tzinfo=value.tzinfo) + return datetime.combine(ensure_datetime(value).date(), MIDNIGHT, tzinfo=value.tzinfo) @overload @@ -296,53 +129,27 @@ def ceil_datetime_to_midnight(value): return value floored = floor_datetime_to_midnight(value) if floored != value: - return floored + TIMEDELTA_CACHE["day"] + return floored + TIMEDELTA_DAY else: return floored def floor_timedelta_to_days(value: timedelta) -> timedelta: - return value - (value % TIMEDELTA_CACHE["day"]) + return value - (value % TIMEDELTA_DAY) def ceil_timedelta_to_days(value: timedelta) -> timedelta: - mod = value % TIMEDELTA_CACHE["day"] - if mod == TIMEDELTA_CACHE[0]: + mod = value % TIMEDELTA_DAY + if mod == TIMEDELTA_ZERO: return value else: - return value + TIMEDELTA_CACHE["day"] - mod + return value + TIMEDELTA_DAY - mod ############################################################################### # String Utils -def get_lines(container: Container, name: str, keep: bool = False) -> ContainerList: - # FIXME this can be done so much faster by using bucketing - lines = [] - for i in reversed(range(len(container))): - item = container[i] - if item.name == name: - lines.append(item) - if not keep: - del container[i] - return lines - - -def remove_x(container: Container) -> None: - for i in reversed(range(len(container))): - item = container[i] - if item.name.startswith('X-'): - del container[i] - - -def remove_sequence(container: Container) -> None: - for i in reversed(range(len(container))): - item = container[i] - if item.name == 'SEQUENCE': - del container[i] - - def uid_gen() -> str: uid = str(uuid4()) return "{}@{}.org".format(uid, uid[:4]) @@ -426,3 +233,7 @@ def validate_utc(inst, attr, value): name=attr.name, value=value, tzinfo=value.tzinfo ) ) + + +def call_validate_on_inst(inst, attr, value): + inst.validate(attr, value) diff --git a/ics/valuetype/__init__.py b/ics/valuetype/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ics/valuetype/base.py b/ics/valuetype/base.py new file mode 100644 index 00000000..cccc13b2 --- /dev/null +++ b/ics/valuetype/base.py @@ -0,0 +1,42 @@ +import abc +import inspect +from typing import Dict, Generic, Type, TypeVar + +T = TypeVar('T') + + +class ValueConverter(abc.ABC, Generic[T]): + BY_NAME: Dict[str, "ValueConverter"] = {} + BY_TYPE: Dict[Type, "ValueConverter"] = {} + INST: "ValueConverter" + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + if not inspect.isabstract(cls): + cls.INST = cls() + ValueConverter.BY_NAME[cls.INST.ics_type] = cls.INST + ValueConverter.BY_TYPE.setdefault(cls.INST.python_type, cls.INST) + + @property + @abc.abstractmethod + def ics_type(self) -> str: + pass + + @property + @abc.abstractmethod + def python_type(self) -> Type[T]: + pass + + @abc.abstractmethod + def parse(self, value: str) -> T: + pass + + @abc.abstractmethod + def serialize(self, value: T) -> str: + pass + + def __str__(self): + return "<" + self.__class__.__name__ + ">" + + def __hash__(self): + return hash(type(self)) diff --git a/ics/valuetype/datetime.py b/ics/valuetype/datetime.py new file mode 100644 index 00000000..78b503ee --- /dev/null +++ b/ics/valuetype/datetime.py @@ -0,0 +1,259 @@ +import re +from datetime import date, datetime, time, timedelta +from typing import Optional, Type, cast + +from dateutil.tz import UTC as dateutil_tzutc, gettz, tzoffset as UTCOffset + +from ics.timespan import Timespan +from ics.types import OptionalTZDict +from ics.utils import ensure_datetime, is_utc +from ics.valuetype.base import ValueConverter + + +class DatetimeConverterMixin(object): + FORMATS = { + 6: "%Y%m", + 8: "%Y%m%d" + } + + def serialize(self, value: datetime) -> str: # type: ignore + if is_utc(value): + return value.strftime('%Y%m%dT%H%M%SZ') + else: + return value.strftime('%Y%m%dT%H%M%S') + + def parse(self, value: str, param_tz: Optional[str] = None, available_tz: OptionalTZDict = None) -> datetime: # type: ignore + # TODO pass and handle available_tz + fixed_utc = (value[-1].upper() == 'Z') + + value = value.translate({ + ord("/"): "", + ord("-"): "", + ord("Z"): "", + ord("z"): ""}) + dt = datetime.strptime(value, self.FORMATS[len(value)]) + + if fixed_utc: + if param_tz: + raise ValueError("can't specify UTC via appended 'Z' and TZID param '%s'" % param_tz) + return dt.replace(tzinfo=dateutil_tzutc) + elif param_tz: + selected_tz = None + if available_tz: + selected_tz = available_tz.get(param_tz, None) + if selected_tz is None: + selected_tz = gettz(param_tz) # be lenient with missing vtimezone definitions + return dt.replace(tzinfo=selected_tz) + else: + return dt + + +class DatetimeConverter(DatetimeConverterMixin, ValueConverter[datetime]): + FORMATS = { + **DatetimeConverterMixin.FORMATS, + 11: "%Y%m%dT%H", + 13: "%Y%m%dT%H%M", + 15: "%Y%m%dT%H%M%S" + } + + @property + def ics_type(self) -> str: + return "DATE-TIME" + + @property + def python_type(self) -> Type[datetime]: + return datetime + + +class DateConverter(DatetimeConverterMixin, ValueConverter[date]): + @property + def ics_type(self) -> str: + return "DATE" + + @property + def python_type(self) -> Type[date]: + return date + + def serialize(self, value): + return value.strftime('%Y%m%d') + + def parse(self, *args, **kwargs): + return super().parse(*args, **kwargs).date() + + +class TimeConverter(DatetimeConverterMixin, ValueConverter[time]): + FORMATS = { + 2: "%H", + 4: "%H%M", + 6: "%H%M%S" + } + + @property + def ics_type(self) -> str: + return "TIME" + + @property + def python_type(self) -> Type[time]: + return time + + def serialize(self, value): + return value.strftime('%H%M%S') + + def parse(self, *args, **kwargs): + return super().parse(*args, **kwargs).timetz() + + +class UTCOffsetConverter(ValueConverter[UTCOffset]): + @property + def ics_type(self) -> str: + return "UTC-OFFSET" + + @property + def python_type(self) -> Type[UTCOffset]: + return UTCOffset + + def parse(self, value: str) -> UTCOffset: + match = re.fullmatch(r"(?P\+|-|)(?P[0-9]{2})(?P[0-9]{2})(?P[0-9]{2})?", value) + if not match: + raise ValueError("value '%s' is not a valid UTCOffset") + groups = match.groupdict() + sign = groups.pop("sign") + td = timedelta(**{k: int(v) for k, v in groups.items() if v}) + if sign == "-": + td *= -1 + return UTCOffset(value, td) + + def serialize(self, value: UTCOffset) -> str: + offset = value.utcoffset(None) + assert offset is not None + seconds = offset.seconds + if seconds < 0: + res = "-" + else: + res = "+" + + # hours + res += '%02d' % (seconds // 3600) + seconds %= 3600 + + # minutes + res += '%02d' % (seconds // 60) + seconds %= 60 + + if seconds: + # seconds + res += '%02d' % seconds + + return res + + +class DurationConverter(ValueConverter[timedelta]): + @property + def ics_type(self) -> str: + return "DURATION" + + @property + def python_type(self) -> Type[timedelta]: + return timedelta + + def parse(self, value: str) -> timedelta: + DAYS = {'D': 1, 'W': 7} + SECS = {'S': 1, 'M': 60, 'H': 3600} + + sign, i = 1, 0 + if value[i] in '-+': + if value[i] == '-': + sign = -1 + i += 1 + if value[i] != 'P': + raise ValueError("Error while parsing %s" % value) + i += 1 + days, secs = 0, 0 + while i < len(value): + if value[i] == 'T': + i += 1 + if i == len(value): + break + j = i + while value[j].isdigit(): + j += 1 + if i == j: + raise ValueError("Error while parsing %s" % value) + val = int(value[i:j]) + if value[j] in DAYS: + days += val * DAYS[value[j]] + DAYS.pop(value[j]) + elif value[j] in SECS: + secs += val * SECS[value[j]] + SECS.pop(value[j]) + else: + raise ValueError("Error while parsing %s" % value) + i = j + 1 + return timedelta(sign * days, sign * secs) + + def serialize(self, value: timedelta) -> str: + ONE_DAY_IN_SECS = 3600 * 24 + total = abs(int(value.total_seconds())) + days = total // ONE_DAY_IN_SECS + seconds = total % ONE_DAY_IN_SECS + + res = '' + if days: + res += str(days) + 'D' + if seconds: + res += 'T' + if seconds // 3600: + res += str(seconds // 3600) + 'H' + seconds %= 3600 + if seconds // 60: + res += str(seconds // 60) + 'M' + seconds %= 60 + if seconds: + res += str(seconds) + 'S' + + if not res: + res = 'T0S' + if value.total_seconds() >= 0: + return 'P' + res + else: + return '-P%s' % res + + +class PeriodConverter(DatetimeConverterMixin, ValueConverter[Timespan]): + + @property + def ics_type(self) -> str: + return "PERIOD" + + @property + def python_type(self) -> Type[Timespan]: + return Timespan + + def parse(self, value: str, *args, **kwargs): + start, sep, end = value.partition("/") + if not sep: + raise ValueError("PERIOD '%s' must contain the separator '/'") + if end.startswith("P"): # period-start = date-time "/" dur-value + return Timespan(begin_time=ensure_datetime(super(PeriodConverter, self).parse(start, *args, **kwargs)), + duration=DurationConverter.INST.parse(end)) + else: # period-explicit = date-time "/" date-time + return Timespan(begin_time=ensure_datetime(super(PeriodConverter, self).parse(start, *args, **kwargs)), + end_time=ensure_datetime(super(PeriodConverter, self).parse(end, *args, **kwargs))) + + def serialize(self, value: Timespan) -> str: # type: ignore + begin = value.get_begin() + if begin is None: + raise ValueError("PERIOD must have a begin timestamp") + if value.get_end_representation() == "duration": + return "%s/%s" % ( + super(PeriodConverter, self).serialize(begin), + DurationConverter.INST.serialize(cast(timedelta, value.get_effective_duration())) + ) + else: + end = value.get_effective_end() + if end is None: + raise ValueError("PERIOD must have a end timestamp") + return "%s/%s" % ( + super(PeriodConverter, self).serialize(begin), + super(PeriodConverter, self).serialize(end) + ) diff --git a/ics/valuetype/generic.py b/ics/valuetype/generic.py new file mode 100644 index 00000000..e56eff35 --- /dev/null +++ b/ics/valuetype/generic.py @@ -0,0 +1,158 @@ +import base64 +from typing import Type +from urllib.parse import ParseResult as URL, urlparse + +from dateutil.rrule import rrule + +from ics.valuetype.base import ValueConverter + + +class TextConverter(ValueConverter[str]): + + @property + def ics_type(self) -> str: + return "TEXT" + + @property + def python_type(self) -> Type[str]: + return str + + def parse(self, value: str) -> str: + return value + + def serialize(self, value: str) -> str: + return value + + +class BinaryConverter(ValueConverter[bytes]): + + @property + def ics_type(self) -> str: + return "BINARY" + + @property + def python_type(self) -> Type[bytes]: + return bytes + + def parse(self, value: str) -> bytes: + return base64.b64decode(value) + + def serialize(self, value: bytes) -> str: + return base64.b64encode(value).decode("ascii") + + +ValueConverter.BY_TYPE[bytearray] = ValueConverter.BY_TYPE[bytes] + + +class BooleanConverter(ValueConverter[bool]): + + @property + def ics_type(self) -> str: + return "BOOLEAN" + + @property + def python_type(self) -> Type[bool]: + return bool + + def parse(self, value: str) -> bool: + if value == "TRUE": + return True + elif value == "FALSE": + return False + else: + value = value.upper() + if value == "TRUE": + return True + elif value == "FALSE": + return False + elif value in ["T", "Y", "YES", "ON", "1"]: + return True + elif value in ["F", "N", "NO", "OFF", "0"]: + return False + else: + raise ValueError("can't interpret '%s' as boolen" % value) + + def serialize(self, value: bool) -> str: + if value: + return "TRUE" + else: + return "FALSE" + + +class IntegerConverter(ValueConverter[int]): + + @property + def ics_type(self) -> str: + return "INTEGER" + + @property + def python_type(self) -> Type[int]: + return int + + def parse(self, value: str) -> int: + return int(value) + + def serialize(self, value: int) -> str: + return str(value) + + +class FloatConverter(ValueConverter[float]): + + @property + def ics_type(self) -> str: + return "FLOAT" + + @property + def python_type(self) -> Type[float]: + return float + + def parse(self, value: str) -> float: + return float(value) + + def serialize(self, value: float) -> str: + return str(value) + + +class RecurConverter(ValueConverter[rrule]): + + @property + def ics_type(self) -> str: + return "RECUR" + + @property + def python_type(self) -> Type[rrule]: + return rrule + + def parse(self, value: str) -> rrule: + # this won't be called unless a class specifies an attribute with type: rrule + raise NotImplementedError("parsing 'RECUR' is not yet supported") + + def serialize(self, value: rrule) -> str: + raise NotImplementedError("serializing 'RECUR' is not yet supported") + + +class URIConverter(ValueConverter[URL]): + + @property + def ics_type(self) -> str: + return "URI" + + @property + def python_type(self) -> Type[URL]: + return URL + + def parse(self, value: str) -> URL: + return urlparse(value) + + def serialize(self, value: URL) -> str: + if isinstance(value, str): + return value + else: + return value.geturl() + + +class CalendarUserAddressConverter(URIConverter): + + @property + def ics_type(self) -> str: + return "CAL-ADDRESS" diff --git a/ics/valuetype/special.py b/ics/valuetype/special.py new file mode 100644 index 00000000..fca79862 --- /dev/null +++ b/ics/valuetype/special.py @@ -0,0 +1,24 @@ +from typing import Type + +from ics.geo import Geo +from ics.valuetype.base import ValueConverter + + +class GeoConverter(ValueConverter[Geo]): + + @property + def ics_type(self) -> str: + return "X-GEO" + + @property + def python_type(self) -> Type[Geo]: + return Geo + + def parse(self, value: str) -> Geo: + latitude, sep, longitude = value.partition(";") + if not sep: + raise ValueError("geo must have two float values") + return Geo(float(latitude), float(longitude)) + + def serialize(self, value: Geo) -> str: + return "%f;%f" % value From 8466e3ac30a4b27216217c459612b76d31367c95 Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Sun, 29 Mar 2020 17:50:56 +0200 Subject: [PATCH 02/43] fixed Timespan end/due extra param handling and further small issues --- ics/converter/timespan.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/ics/converter/timespan.py b/ics/converter/timespan.py index 4bf6c3e1..67bee3aa 100644 --- a/ics/converter/timespan.py +++ b/ics/converter/timespan.py @@ -23,6 +23,12 @@ def filter_ics_names(self) -> List[str]: def populate(self, component: "Component", item: ContainerItem, context: Dict) -> bool: assert isinstance(item, ContentLine) self._check_component(component, context) + + seen_items = context.setdefault("timespan_items", set()) + if item.name in seen_items: + raise ValueError("duplicate value for %s in %s" % (item.name, item)) + seen_items.add(item.name) + params = dict(item.params) if item.name in ["DTSTART", "DTEND", "DUE"]: value_type = params.pop("VALUE", ["DATE-TIME"]) @@ -49,7 +55,9 @@ def populate(self, component: "Component", item: ContainerItem, context: Dict) - self.set_or_append_extra_params(component, params, name="begin") context["timespan_begin_time"] = value else: - self.set_or_append_extra_params(component, params, name="end") # FIXME due? + end_name = {"DTEND": "end", "DUE": "due"}[item.name] + context["timespan_end_name"] = end_name + self.set_or_append_extra_params(component, params, name=end_name) context["timespan_end_time"] = value else: @@ -61,11 +69,18 @@ def populate(self, component: "Component", item: ContainerItem, context: Dict) - def finalize(self, component: "Component", context: Dict): self._check_component(component, context) - self.set_or_append_value(component, self.value_type( - ensure_datetime(context["timespan_begin_time"]), - ensure_datetime(context["timespan_end_time"]), - context["timespan_duration"], context["timespan_precision"])) + # missing values will be reported by the Timespan validator + timespan = self.value_type( + ensure_datetime(context["timespan_begin_time"]), ensure_datetime(context["timespan_end_time"]), + context["timespan_duration"], context["timespan_precision"]) + if context["timespan_end_name"] and context["timespan_end_name"] != timespan._end_name(): + raise ValueError("expected to get %s value, but got %s instead" + % (timespan._end_name(), context["timespan_end_name"])) + self.set_or_append_value(component, timespan) super(TimespanConverter, self).finalize(component, context) + # we need to clear all values, otherwise they might not get overwritten by the next parsed Timespan + context["timespan_begin_time"] = context["timespan_end_time"] = context["timespan_duration"] \ + = context["timespan_precision"] = context["timespan_end_name"] = None def serialize(self, component: "Component", output: Container, context: Dict): self._check_component(component, context) @@ -74,7 +89,7 @@ def serialize(self, component: "Component", output: Container, context: Dict): value_type = {"VALUE": ["DATE"]} dt_conv = DateConverter.INST else: - value_type = {"VALUE": ["DATE-TIME"]} + value_type = {} # implicit default is {"VALUE": ["DATE-TIME"]} dt_conv = DatetimeConverter.INST if value.get_begin(): From b05ce7d96eba5430f0e3bafde1e1b9f5f08e0f7e Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Sun, 29 Mar 2020 17:51:27 +0200 Subject: [PATCH 03/43] ensure that event modifications times are in UTC --- ics/event.py | 9 ++++----- ics/utils.py | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/ics/event.py b/ics/event.py index 3d46fbb7..21538139 100644 --- a/ics/event.py +++ b/ics/event.py @@ -14,7 +14,7 @@ from ics.geo import Geo, make_geo from ics.timespan import EventTimespan, Timespan from ics.types import DatetimeLike, EventOrTimespan, EventOrTimespanOrInstant, TimedeltaLike, get_timespan_if_calendar_entry -from ics.utils import check_is_instance, ensure_datetime, ensure_timedelta, now_in_utc, uid_gen, validate_not_none +from ics.utils import check_is_instance, ensure_datetime, ensure_timedelta, ensure_utc, now_in_utc, uid_gen, validate_not_none STATUS_VALUES = (None, 'TENTATIVE', 'CONFIRMED', 'CANCELLED') @@ -30,10 +30,9 @@ class CalendarEntryAttrs(Component): url: Optional[str] = attr.ib(default=None) status: Optional[str] = attr.ib(default=None, converter=c_optional(str.upper), validator=v_optional(in_(STATUS_VALUES))) # type: ignore - # TODO these three timestamps must be in UTC according to the RFC - created: Optional[datetime] = attr.ib(default=None, converter=ensure_datetime) # type: ignore - last_modified: Optional[datetime] = attr.ib(default=None, converter=ensure_datetime) # type: ignore - dtstamp: datetime = attr.ib(factory=now_in_utc, converter=ensure_datetime, validator=validate_not_none) # type: ignore + created: Optional[datetime] = attr.ib(default=None, converter=ensure_utc) # type: ignore + last_modified: Optional[datetime] = attr.ib(default=None, converter=ensure_utc) # type: ignore + dtstamp: datetime = attr.ib(factory=now_in_utc, converter=ensure_utc, validator=validate_not_none) # type: ignore alarms: List[BaseAlarm] = attr.ib(factory=list, converter=list) diff --git a/ics/utils.py b/ics/utils.py index 0c09aef6..3434a50c 100644 --- a/ics/utils.py +++ b/ics/utils.py @@ -43,6 +43,21 @@ def ensure_datetime(value): raise ValueError("can't construct datetime from %s" % repr(value)) +@overload +def ensure_utc(value: None) -> None: ... + + +@overload +def ensure_utc(value: DatetimeLike) -> datetime: ... + + +def ensure_utc(value): + value = ensure_datetime(value) + if value is not None: + value = value.astimezone(dateutil_tzutc) + return value + + def now_in_utc() -> datetime: return datetime.now(tz=dateutil_tzutc) From dc8923c72892c39f2b53d17c0e1cdb82f97e04bf Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Sat, 4 Apr 2020 15:06:11 +0200 Subject: [PATCH 04/43] pass params and context to ValueConverter for timezone handling ValueConverters are now allowed to modify optional params and context, i.e. consume params and store context when parsing, and add params when serializing. also move ExtraParams to types, use NewType instead of a direct alias to catch invalid dict usage, and ensure that they are copied using deep-copy (they might contain lists), add EmptyDict as argument default, fix timespan context clean-up --- ics/component.py | 3 +- ics/converter/base.py | 12 ++--- ics/converter/timespan.py | 69 +++++++++++++++------------ ics/converter/value.py | 43 +++++++++-------- ics/types.py | 41 +++++++++++++--- ics/valuetype/base.py | 8 ++-- ics/valuetype/datetime.py | 98 ++++++++++++++++++++++++++------------- ics/valuetype/generic.py | 31 +++++++------ ics/valuetype/special.py | 7 +-- 9 files changed, 194 insertions(+), 118 deletions(-) diff --git a/ics/component.py b/ics/component.py index 2c75f1f9..89655f0e 100644 --- a/ics/component.py +++ b/ics/component.py @@ -5,11 +5,10 @@ from ics.converter.component import ComponentMeta, InflatedComponentMeta from ics.grammar import Container -from ics.types import RuntimeAttrValidation +from ics.types import ExtraParams, RuntimeAttrValidation PLACEHOLDER_CONTAINER = Container("PLACEHOLDER") ComponentType = TypeVar('ComponentType', bound='Component') -ExtraParams = Dict[str, List[str]] ComponentExtraParams = Dict[str, Union[ExtraParams, List[ExtraParams]]] diff --git a/ics/converter/base.py b/ics/converter/base.py index 982c67ed..6d98efeb 100644 --- a/ics/converter/base.py +++ b/ics/converter/base.py @@ -5,10 +5,10 @@ import attr from ics.grammar import Container -from ics.types import ContainerItem +from ics.types import ContainerItem, ExtraParams if TYPE_CHECKING: - from ics.component import Component, ExtraParams + from ics.component import Component from ics.converter.component import InflatedComponentMeta @@ -99,16 +99,16 @@ def get_value_list(self, component: "Component") -> List[Any]: else: return [self.get_value(component)] - def set_or_append_extra_params(self, component: "Component", value: "ExtraParams", name: Optional[str] = None): + def set_or_append_extra_params(self, component: "Component", value: ExtraParams, name: Optional[str] = None): name = name or self.attribute.name if self.is_multi_value: extras = component.extra_params.setdefault(name, []) - cast(List["ExtraParams"], extras).append(value) + cast(List[ExtraParams], extras).append(value) elif value: component.extra_params[name] = value - def get_extra_params(self, component: "Component", name: Optional[str] = None) -> Union["ExtraParams", List["ExtraParams"]]: - default: Union["ExtraParams", List["ExtraParams"]] = list() if self.multi_value_type else dict() + def get_extra_params(self, component: "Component", name: Optional[str] = None) -> Union[ExtraParams, List[ExtraParams]]: + default: Union[ExtraParams, List[ExtraParams]] = list() if self.multi_value_type else dict() name = name or self.attribute.name return component.extra_params.get(name, default) diff --git a/ics/converter/timespan.py b/ics/converter/timespan.py index 67bee3aa..10b70416 100644 --- a/ics/converter/timespan.py +++ b/ics/converter/timespan.py @@ -3,12 +3,21 @@ from ics.converter.base import AttributeConverter from ics.grammar import Container, ContentLine from ics.timespan import EventTimespan, Timespan, TodoTimespan -from ics.types import ContainerItem +from ics.types import ContainerItem, ExtraParams, copy_extra_params from ics.utils import ensure_datetime from ics.valuetype.datetime import DateConverter, DatetimeConverter, DurationConverter if TYPE_CHECKING: - from ics.component import Component, ExtraParams + from ics.component import Component + +CONTEXT_BEGIN_TIME = "timespan_begin_time" +CONTEXT_END_TIME = "timespan_end_time" +CONTEXT_DURATION = "timespan_duration" +CONTEXT_PRECISION = "timespan_precision" +CONTEXT_END_NAME = "timespan_end_name" +CONTEXT_ITEMS = "timespan_items" +CONTEXT_KEYS = [CONTEXT_BEGIN_TIME, CONTEXT_END_TIME, CONTEXT_DURATION, + CONTEXT_PRECISION, CONTEXT_END_NAME, CONTEXT_ITEMS] class TimespanConverter(AttributeConverter): @@ -24,12 +33,12 @@ def populate(self, component: "Component", item: ContainerItem, context: Dict) - assert isinstance(item, ContentLine) self._check_component(component, context) - seen_items = context.setdefault("timespan_items", set()) + seen_items = context.setdefault(CONTEXT_ITEMS, set()) if item.name in seen_items: raise ValueError("duplicate value for %s in %s" % (item.name, item)) seen_items.add(item.name) - params = dict(item.params) + params = copy_extra_params(item.params) if item.name in ["DTSTART", "DTEND", "DUE"]: value_type = params.pop("VALUE", ["DATE-TIME"]) if value_type == ["DATE-TIME"]: @@ -39,31 +48,31 @@ def populate(self, component: "Component", item: ContainerItem, context: Dict) - else: raise ValueError("can't handle %s with value type %s" % (item.name, value_type)) - if context["timespan_precision"] is None: - context["timespan_precision"] = precision + if context[CONTEXT_PRECISION] is None: + context[CONTEXT_PRECISION] = precision else: - if context["timespan_precision"] != precision: + if context[CONTEXT_PRECISION] != precision: raise ValueError("event with diverging begin and end time precision") if precision == "day": - value = DateConverter.INST.parse(item.value) + value = DateConverter.INST.parse(item.value, params, context) else: assert precision == "second" - value = DatetimeConverter.INST.parse(item.value) + value = DatetimeConverter.INST.parse(item.value, params, context) if item.name == "DTSTART": self.set_or_append_extra_params(component, params, name="begin") - context["timespan_begin_time"] = value + context[CONTEXT_BEGIN_TIME] = value else: end_name = {"DTEND": "end", "DUE": "due"}[item.name] - context["timespan_end_name"] = end_name + context[CONTEXT_END_NAME] = end_name self.set_or_append_extra_params(component, params, name=end_name) - context["timespan_end_time"] = value + context[CONTEXT_END_TIME] = value else: assert item.name == "DURATION" self.set_or_append_extra_params(component, params, name="duration") - context["timespan_duration"] = DurationConverter.INST.parse(item.value) + context[CONTEXT_DURATION] = DurationConverter.INST.parse(item.value, params, context) return True @@ -71,16 +80,16 @@ def finalize(self, component: "Component", context: Dict): self._check_component(component, context) # missing values will be reported by the Timespan validator timespan = self.value_type( - ensure_datetime(context["timespan_begin_time"]), ensure_datetime(context["timespan_end_time"]), - context["timespan_duration"], context["timespan_precision"]) - if context["timespan_end_name"] and context["timespan_end_name"] != timespan._end_name(): + ensure_datetime(context[CONTEXT_BEGIN_TIME]), ensure_datetime(context[CONTEXT_END_TIME]), + context[CONTEXT_DURATION], context[CONTEXT_PRECISION]) + if context[CONTEXT_END_NAME] and context[CONTEXT_END_NAME] != timespan._end_name(): raise ValueError("expected to get %s value, but got %s instead" - % (timespan._end_name(), context["timespan_end_name"])) + % (timespan._end_name(), context[CONTEXT_END_NAME])) self.set_or_append_value(component, timespan) super(TimespanConverter, self).finalize(component, context) # we need to clear all values, otherwise they might not get overwritten by the next parsed Timespan - context["timespan_begin_time"] = context["timespan_end_time"] = context["timespan_duration"] \ - = context["timespan_precision"] = context["timespan_end_name"] = None + for key in CONTEXT_KEYS: + context.pop(key, None) def serialize(self, component: "Component", output: Container, context: Dict): self._check_component(component, context) @@ -93,22 +102,22 @@ def serialize(self, component: "Component", output: Container, context: Dict): dt_conv = DatetimeConverter.INST if value.get_begin(): - params: "ExtraParams" = cast("ExtraParams", self.get_extra_params(component, "begin")) - params = dict(**params, **value_type) - output.append(ContentLine(name="DTSTART", params=params, value=dt_conv.serialize(value.get_begin()))) + params = copy_extra_params(cast(ExtraParams, self.get_extra_params(component, "begin"))) + params.update(value_type) + dt_value = dt_conv.serialize(value.get_begin(), params, context) + output.append(ContentLine(name="DTSTART", params=params, value=dt_value)) if value.get_end_representation() == "end": end_name = {"end": "DTEND", "due": "DUE"}[value._end_name()] - params = cast("ExtraParams", self.get_extra_params(component, end_name)) - params = dict(**params, **value_type) - output.append(ContentLine(name=end_name, params=params, value=dt_conv.serialize(value.get_effective_end()))) + params = copy_extra_params(cast(ExtraParams, self.get_extra_params(component, end_name))) + params.update(value_type) + dt_value = dt_conv.serialize(value.get_effective_end(), params, context) + output.append(ContentLine(name=end_name, params=params, value=dt_value)) elif value.get_end_representation() == "duration": - params = cast("ExtraParams", self.get_extra_params(component, "duration")) - output.append(ContentLine( - name="DURATION", - params=params, - value=DurationConverter.INST.serialize(value.get_effective_duration()))) + params = copy_extra_params(cast(ExtraParams, self.get_extra_params(component, "duration"))) + dur_value = DurationConverter.INST.serialize(value.get_effective_duration(), params, context) + output.append(ContentLine(name="DURATION", params=params, value=dur_value)) AttributeConverter.BY_TYPE[Timespan] = TimespanConverter diff --git a/ics/converter/value.py b/ics/converter/value.py index 630e7a29..8245944d 100644 --- a/ics/converter/value.py +++ b/ics/converter/value.py @@ -4,11 +4,11 @@ from ics.converter.base import AttributeConverter from ics.grammar import Container, ContentLine -from ics.types import ContainerItem +from ics.types import ContainerItem, ExtraParams, copy_extra_params from ics.valuetype.base import ValueConverter if TYPE_CHECKING: - from ics.component import Component, ExtraParams + from ics.component import Component @attr.s(frozen=True) @@ -35,8 +35,8 @@ def ics_name(self) -> str: name = self.attribute.name.upper().replace("_", "-").strip("-") return name - def __find_line_converter(self, line: "ContentLine") -> Tuple["ExtraParams", ValueConverter]: - params = line.params + def __parse_value(self, line: "ContentLine", value: str, context: Dict) -> Tuple[ExtraParams, ValueConverter]: + params = copy_extra_params(line.params) value_type = params.pop("VALUE", None) if value_type: if len(value_type) != 1: @@ -48,7 +48,10 @@ def __find_line_converter(self, line: "ContentLine") -> Tuple["ExtraParams", Val raise ValueError("can't convert %s with %s" % (line, self)) else: converter = self.value_converters[0] - return params, converter + parsed = converter.parse(value, params, context) # might modify params and context + return params, parsed + + # TODO make storing/writing extra values/params configurably optional, but warn when information is lost def populate(self, component: "Component", item: ContainerItem, context: Dict) -> bool: assert isinstance(item, ContentLine) @@ -57,19 +60,19 @@ def populate(self, component: "Component", item: ContainerItem, context: Dict) - params = None for value in item.value_list: context[(self, "current_value_count")] += 1 - params, converter = self.__find_line_converter(item) + params, parsed = self.__parse_value(item, value, context) params["__merge_next"] = True # type: ignore self.set_or_append_extra_params(component, params) - self.set_or_append_value(component, converter.parse(value)) + self.set_or_append_value(component, parsed) if params is not None: params["__merge_next"] = False # type: ignore else: if context[(self, "current_value_count")] > 0: raise ValueError("attribute %s can only be set once, second occurrence is %s" % (self.ics_name, item)) context[(self, "current_value_count")] += 1 - params, converter = self.__find_line_converter(item) + params, parsed = self.__parse_value(item, item.value, context) self.set_or_append_extra_params(component, params) - self.set_or_append_value(component, converter.parse(item.value)) + self.set_or_append_value(component, parsed) return True def finalize(self, component: "Component", context: Dict): @@ -78,7 +81,7 @@ def finalize(self, component: "Component", context: Dict): raise ValueError("attribute %s is required but got no value" % self.ics_name) super(AttributeValueConverter, self).finalize(component, context) - def __find_value_converter(self, params: "ExtraParams", value: Any) -> ValueConverter: + def __find_value_converter(self, params: ExtraParams, value: Any) -> ValueConverter: for nr, converter in enumerate(self.value_converters): if not isinstance(value, converter.python_type): continue if nr > 0: @@ -93,15 +96,13 @@ def serialize(self, component: "Component", output: Container, context: Dict): else: value = self.get_value(component) if value: - params = cast("ExtraParams", self.get_extra_params(component)) + params = copy_extra_params(cast(ExtraParams, self.get_extra_params(component))) converter = self.__find_value_converter(params, value) - output.append(ContentLine( - name=self.ics_name, - params=params, - value=converter.serialize(value))) + serialized = converter.serialize(value, params, context) + output.append(ContentLine(name=self.ics_name, params=params, value=serialized)) def __serialize_multi(self, component: "Component", output: "Container", context: Dict): - extra_params = cast(List["ExtraParams"], self.get_extra_params(component)) + extra_params = cast(List[ExtraParams], self.get_extra_params(component)) values = self.get_value_list(component) if len(extra_params) != len(values): raise ValueError("length of extra params doesn't match length of parameters" @@ -113,20 +114,22 @@ def __serialize_multi(self, component: "Component", output: "Container", context for value, params in zip(values, extra_params): merge_next = False - params = dict(params) - if params and params.pop("__merge_next", False): # type: ignore + params = copy_extra_params(params) + if params.pop("__merge_next", False): # type: ignore merge_next = True converter = self.__find_value_converter(params, value) + serialized = converter.serialize(value, params, context) # might modify params and context if current_params is not None: if current_params != params: raise ValueError() else: current_params = params - current_values.append(converter.serialize(value)) + + current_values.append(serialized) if not merge_next: - cl = ContentLine(name=self.ics_name, params=current_params) + cl = ContentLine(name=self.ics_name, params=params) cl.value_list = current_values output.append(cl) current_params = None diff --git a/ics/types.py b/ics/types.py index 725fdcbd..31687ffb 100644 --- a/ics/types.py +++ b/ics/types.py @@ -1,5 +1,5 @@ -from datetime import date, datetime, timedelta, tzinfo -from typing import Any, Dict, List, Optional, TYPE_CHECKING, Tuple, Union, overload +from datetime import date, datetime, timedelta +from typing import Any, Dict, Iterator, List, Mapping, NewType, Optional, TYPE_CHECKING, Tuple, Union, overload import attr @@ -27,11 +27,11 @@ "CalendarEntryOrTimespan", "CalendarEntryOrTimespanOrInstant", - "OptionalTZDict", - "get_timespan_if_calendar_entry", "RuntimeAttrValidation", + + "EmptyDict", "EmptyDictType", "ExtraParams", "copy_extra_params", ] ContainerItem = Union["ContentLine", "Container"] @@ -50,8 +50,6 @@ CalendarEntryOrTimespan = Union["CalendarEntryAttrs", "Timespan"] CalendarEntryOrTimespanOrInstant = Union["CalendarEntryAttrs", "Timespan", datetime] -OptionalTZDict = Optional[Dict[str, tzinfo]] - @overload def get_timespan_if_calendar_entry(value: CalendarEntryOrTimespan) -> "Timespan": @@ -106,3 +104,34 @@ def __setattr__(self, key, value): if field.validator is not None: field.validator(self, field, value) super(RuntimeAttrValidation, self).__setattr__(key, value) + + +class EmptyDictType(Mapping[Any, None]): + """An empty, immutable dict that returns `None` for any key. Useful as default value for function arguments.""" + + def __getitem__(self, k: Any) -> None: + return None + + def __len__(self) -> int: + return 0 + + def __iter__(self) -> Iterator[Any]: + return iter([]) + + +EmptyDict = EmptyDictType() +ExtraParams = NewType('ExtraParams', Dict[str, List[str]]) + + +def copy_extra_params(old: Optional[ExtraParams]) -> ExtraParams: + new: ExtraParams = ExtraParams(dict()) + if not old: + return new + for key, value in old.items(): + if isinstance(value, str): + new[key] = value + elif isinstance(value, list): + new[key] = list(value) + else: + raise ValueError("can't convert extra param %s with value of type %s: %s" % (key, type(value), value)) + return new diff --git a/ics/valuetype/base.py b/ics/valuetype/base.py index cccc13b2..e38b5df5 100644 --- a/ics/valuetype/base.py +++ b/ics/valuetype/base.py @@ -2,13 +2,15 @@ import inspect from typing import Dict, Generic, Type, TypeVar +from ics.types import EmptyDict, ExtraParams + T = TypeVar('T') class ValueConverter(abc.ABC, Generic[T]): BY_NAME: Dict[str, "ValueConverter"] = {} BY_TYPE: Dict[Type, "ValueConverter"] = {} - INST: "ValueConverter" + INST: "ValueConverter[T]" def __init_subclass__(cls) -> None: super().__init_subclass__() @@ -28,11 +30,11 @@ def python_type(self) -> Type[T]: pass @abc.abstractmethod - def parse(self, value: str) -> T: + def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> T: pass @abc.abstractmethod - def serialize(self, value: T) -> str: + def serialize(self, value: T, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: pass def __str__(self): diff --git a/ics/valuetype/datetime.py b/ics/valuetype/datetime.py index 78b503ee..40968bcd 100644 --- a/ics/valuetype/datetime.py +++ b/ics/valuetype/datetime.py @@ -1,13 +1,14 @@ import re +import warnings from datetime import date, datetime, time, timedelta -from typing import Optional, Type, cast +from typing import Dict, List, Optional, Type, cast from dateutil.tz import UTC as dateutil_tzutc, gettz, tzoffset as UTCOffset from ics.timespan import Timespan -from ics.types import OptionalTZDict -from ics.utils import ensure_datetime, is_utc -from ics.valuetype.base import ValueConverter +from ics.types import EmptyDict, ExtraParams, copy_extra_params +from ics.utils import is_utc +from ics.valuetype.base import T, ValueConverter class DatetimeConverterMixin(object): @@ -15,15 +16,32 @@ class DatetimeConverterMixin(object): 6: "%Y%m", 8: "%Y%m%d" } + CONTEXT_KEY_AVAILABLE_TZ = "DatetimeAvailableTimezones" - def serialize(self, value: datetime) -> str: # type: ignore + def _serialize_dt(self, value: datetime, params: ExtraParams, context: Dict, + utc_fmt="%Y%m%dT%H%M%SZ", nonutc_fmt="%Y%m%dT%H%M%S") -> str: if is_utc(value): - return value.strftime('%Y%m%dT%H%M%SZ') + return value.strftime(utc_fmt) else: - return value.strftime('%Y%m%dT%H%M%S') - - def parse(self, value: str, param_tz: Optional[str] = None, available_tz: OptionalTZDict = None) -> datetime: # type: ignore - # TODO pass and handle available_tz + if value.tzinfo is not None: + tzname = value.tzinfo.tzname(value) + params["TZID"] = [tzname] + available_tz = context.setdefault(self.CONTEXT_KEY_AVAILABLE_TZ, {}) + available_tz.setdefault(tzname, value.tzinfo) + return value.strftime(nonutc_fmt) + + def _parse_dt(self, value: str, params: ExtraParams, context: Dict, + warn_no_avail_tz=True) -> datetime: + param_tz_list: Optional[List[str]] = params.pop("TZID", None) # we remove the TZID from context + if param_tz_list: + if len(param_tz_list) > 1: + raise ValueError("got multiple TZIDs") + param_tz = param_tz_list[0] + else: + param_tz = None + available_tz = context.get(self.CONTEXT_KEY_AVAILABLE_TZ, None) + if available_tz is None and warn_no_avail_tz: + warnings.warn("DatetimeConverterMixin.parse called without available_tz dict in context") fixed_utc = (value[-1].upper() == 'Z') value = value.translate({ @@ -64,6 +82,12 @@ def ics_type(self) -> str: def python_type(self) -> Type[datetime]: return datetime + def serialize(self, value: T, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: + return self._serialize_dt(value, params, context) + + def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> datetime: + return self._parse_dt(value, params, context) + class DateConverter(DatetimeConverterMixin, ValueConverter[date]): @property @@ -74,11 +98,11 @@ def ics_type(self) -> str: def python_type(self) -> Type[date]: return date - def serialize(self, value): - return value.strftime('%Y%m%d') + def serialize(self, value, params: ExtraParams = EmptyDict, context: Dict = EmptyDict): + return value.strftime("%Y%m%d") - def parse(self, *args, **kwargs): - return super().parse(*args, **kwargs).date() + def parse(self, value, params: ExtraParams = EmptyDict, context: Dict = EmptyDict): + return self._parse_dt(value, params, context, warn_no_avail_tz=False).date() class TimeConverter(DatetimeConverterMixin, ValueConverter[time]): @@ -96,11 +120,11 @@ def ics_type(self) -> str: def python_type(self) -> Type[time]: return time - def serialize(self, value): - return value.strftime('%H%M%S') + def serialize(self, value, params: ExtraParams = EmptyDict, context: Dict = EmptyDict): + return self._serialize_dt(value, params, context, utc_fmt="%H%M%SZ", nonutc_fmt="%H%M%S") - def parse(self, *args, **kwargs): - return super().parse(*args, **kwargs).timetz() + def parse(self, value, params: ExtraParams = EmptyDict, context: Dict = EmptyDict): + return self._parse_dt(value, params, context).timetz() class UTCOffsetConverter(ValueConverter[UTCOffset]): @@ -112,7 +136,7 @@ def ics_type(self) -> str: def python_type(self) -> Type[UTCOffset]: return UTCOffset - def parse(self, value: str) -> UTCOffset: + def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> UTCOffset: match = re.fullmatch(r"(?P\+|-|)(?P[0-9]{2})(?P[0-9]{2})(?P[0-9]{2})?", value) if not match: raise ValueError("value '%s' is not a valid UTCOffset") @@ -123,7 +147,7 @@ def parse(self, value: str) -> UTCOffset: td *= -1 return UTCOffset(value, td) - def serialize(self, value: UTCOffset) -> str: + def serialize(self, value: UTCOffset, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: offset = value.utcoffset(None) assert offset is not None seconds = offset.seconds @@ -156,7 +180,7 @@ def ics_type(self) -> str: def python_type(self) -> Type[timedelta]: return timedelta - def parse(self, value: str) -> timedelta: + def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> timedelta: DAYS = {'D': 1, 'W': 7} SECS = {'S': 1, 'M': 60, 'H': 3600} @@ -191,7 +215,7 @@ def parse(self, value: str) -> timedelta: i = j + 1 return timedelta(sign * days, sign * secs) - def serialize(self, value: timedelta) -> str: + def serialize(self, value: timedelta, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: ONE_DAY_IN_SECS = 3600 * 24 total = abs(int(value.total_seconds())) days = total // ONE_DAY_IN_SECS @@ -229,31 +253,39 @@ def ics_type(self) -> str: def python_type(self) -> Type[Timespan]: return Timespan - def parse(self, value: str, *args, **kwargs): + def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict): start, sep, end = value.partition("/") if not sep: raise ValueError("PERIOD '%s' must contain the separator '/'") if end.startswith("P"): # period-start = date-time "/" dur-value - return Timespan(begin_time=ensure_datetime(super(PeriodConverter, self).parse(start, *args, **kwargs)), - duration=DurationConverter.INST.parse(end)) + return Timespan(begin_time=self._parse_dt(start, params, context), + duration=DurationConverter.INST.parse(end, params, context)) else: # period-explicit = date-time "/" date-time - return Timespan(begin_time=ensure_datetime(super(PeriodConverter, self).parse(start, *args, **kwargs)), - end_time=ensure_datetime(super(PeriodConverter, self).parse(end, *args, **kwargs))) + end_params = copy_extra_params(params) # ensure that the first parse doesn't remove TZID also needed by the second call + return Timespan(begin_time=self._parse_dt(start, params, context), + end_time=self._parse_dt(end, end_params, context)) - def serialize(self, value: Timespan) -> str: # type: ignore + def serialize(self, value: Timespan, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: + # note: there are no DATE to DATE / all-day periods begin = value.get_begin() if begin is None: raise ValueError("PERIOD must have a begin timestamp") if value.get_end_representation() == "duration": + duration = cast(timedelta, value.get_effective_duration()) return "%s/%s" % ( - super(PeriodConverter, self).serialize(begin), - DurationConverter.INST.serialize(cast(timedelta, value.get_effective_duration())) + self._serialize_dt(begin, params, context), + DurationConverter.INST.serialize(duration, params, context) ) else: end = value.get_effective_end() if end is None: raise ValueError("PERIOD must have a end timestamp") - return "%s/%s" % ( - super(PeriodConverter, self).serialize(begin), - super(PeriodConverter, self).serialize(end) + end_params = copy_extra_params(params) + res = "%s/%s" % ( + self._serialize_dt(begin, params, context), + self._serialize_dt(end, end_params, context) ) + if end_params != params: + raise ValueError("Begin and end time of PERIOD %s must serialize to the same params! " + "Got %s != %s." % (value, params, end_params)) + return res diff --git a/ics/valuetype/generic.py b/ics/valuetype/generic.py index e56eff35..7acf541b 100644 --- a/ics/valuetype/generic.py +++ b/ics/valuetype/generic.py @@ -1,9 +1,10 @@ import base64 -from typing import Type +from typing import Dict, Type from urllib.parse import ParseResult as URL, urlparse from dateutil.rrule import rrule +from ics.types import EmptyDict, ExtraParams from ics.valuetype.base import ValueConverter @@ -17,10 +18,10 @@ def ics_type(self) -> str: def python_type(self) -> Type[str]: return str - def parse(self, value: str) -> str: + def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: return value - def serialize(self, value: str) -> str: + def serialize(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: return value @@ -34,10 +35,10 @@ def ics_type(self) -> str: def python_type(self) -> Type[bytes]: return bytes - def parse(self, value: str) -> bytes: + def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> bytes: return base64.b64decode(value) - def serialize(self, value: bytes) -> str: + def serialize(self, value: bytes, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: return base64.b64encode(value).decode("ascii") @@ -54,7 +55,7 @@ def ics_type(self) -> str: def python_type(self) -> Type[bool]: return bool - def parse(self, value: str) -> bool: + def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> bool: if value == "TRUE": return True elif value == "FALSE": @@ -72,7 +73,7 @@ def parse(self, value: str) -> bool: else: raise ValueError("can't interpret '%s' as boolen" % value) - def serialize(self, value: bool) -> str: + def serialize(self, value: bool, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: if value: return "TRUE" else: @@ -89,10 +90,10 @@ def ics_type(self) -> str: def python_type(self) -> Type[int]: return int - def parse(self, value: str) -> int: + def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> int: return int(value) - def serialize(self, value: int) -> str: + def serialize(self, value: int, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: return str(value) @@ -106,10 +107,10 @@ def ics_type(self) -> str: def python_type(self) -> Type[float]: return float - def parse(self, value: str) -> float: + def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> float: return float(value) - def serialize(self, value: float) -> str: + def serialize(self, value: float, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: return str(value) @@ -123,11 +124,11 @@ def ics_type(self) -> str: def python_type(self) -> Type[rrule]: return rrule - def parse(self, value: str) -> rrule: + def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> rrule: # this won't be called unless a class specifies an attribute with type: rrule raise NotImplementedError("parsing 'RECUR' is not yet supported") - def serialize(self, value: rrule) -> str: + def serialize(self, value: rrule, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: raise NotImplementedError("serializing 'RECUR' is not yet supported") @@ -141,10 +142,10 @@ def ics_type(self) -> str: def python_type(self) -> Type[URL]: return URL - def parse(self, value: str) -> URL: + def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> URL: return urlparse(value) - def serialize(self, value: URL) -> str: + def serialize(self, value: URL, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: if isinstance(value, str): return value else: diff --git a/ics/valuetype/special.py b/ics/valuetype/special.py index fca79862..ecbe1321 100644 --- a/ics/valuetype/special.py +++ b/ics/valuetype/special.py @@ -1,6 +1,7 @@ -from typing import Type +from typing import Dict, Type from ics.geo import Geo +from ics.types import EmptyDict, ExtraParams from ics.valuetype.base import ValueConverter @@ -14,11 +15,11 @@ def ics_type(self) -> str: def python_type(self) -> Type[Geo]: return Geo - def parse(self, value: str) -> Geo: + def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> Geo: latitude, sep, longitude = value.partition(";") if not sep: raise ValueError("geo must have two float values") return Geo(float(latitude), float(longitude)) - def serialize(self, value: Geo) -> str: + def serialize(self, value: Geo, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: return "%f;%f" % value From eae493dd3e5f1b9672d1b449b475f13fd3fc190b Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Sat, 4 Apr 2020 23:06:37 +0200 Subject: [PATCH 05/43] rename Event.name to the RFC compliant summary --- doc/event-cmp.rst | 14 ++++---- ics/event.py | 12 +++---- ics/icalendar.py | 2 +- tests/event.py | 92 +++++++++++++++++++++++------------------------ tests/todo.py | 12 +++---- 5 files changed, 66 insertions(+), 66 deletions(-) diff --git a/doc/event-cmp.rst b/doc/event-cmp.rst index 95eccdbb..1301fc67 100644 --- a/doc/event-cmp.rst +++ b/doc/event-cmp.rst @@ -66,7 +66,7 @@ attributes. >>> e = ics.Event() >>> e # doctest: +ELLIPSIS - Event(extra=Container('VEVENT', []), extra_params={}, _timespan=EventTimespan(begin_time=None, end_time=None, duration=None, precision='second'), name=None, uid='...@....org', description=None, location=None, url=None, status=None, created=None, last_modified=None, dtstamp=datetime.datetime(2020, ..., tzinfo=tzutc()), alarms=[], classification=None, transparent=None, organizer=None, geo=None, attendees=[], categories=[]) + Event(extra=Container('VEVENT', []), extra_params={}, _timespan=EventTimespan(begin_time=None, end_time=None, duration=None, precision='second'), summary=None, uid='...@....org', description=None, location=None, url=None, status=None, created=None, last_modified=None, dtstamp=datetime.datetime(2020, ..., tzinfo=tzutc()), alarms=[], classification=None, transparent=None, organizer=None, geo=None, attendees=[], categories=[]) >>> e.to_container().serialize() # doctest: +ELLIPSIS 'BEGIN:VEVENT\r\nUID:...@....org\r\nDTSTAMP:2020...T...Z\r\nEND:VEVENT' >>> import attr, pprint @@ -87,7 +87,7 @@ attributes. 'geo': None, 'last_modified': None, 'location': None, - 'name': None, + 'summary': None, 'organizer': None, 'status': None, 'transparent': None, @@ -98,8 +98,8 @@ Ordering -------- TL;DR: ``Event``\ s are ordered by their attributes ``begin``, ``end``, -and ``name``, in that exact order. For ``Todo``\ s the order is ``due``, -``begin``, then ``name``. It doesn’t matter whether ``duration`` is set +and ``summary``, in that exact order. For ``Todo``\ s the order is ``due``, +``begin``, then ``summary``. It doesn’t matter whether ``duration`` is set instead of ``end`` or ``due``, as the effective end / due time will be compared. Instances where an attribute isn’t set will be sorted before instances where the respective attribute is set. Naive ``datetime``\ s @@ -136,14 +136,14 @@ the timespan, as only the effective end time is compared. False The classes ``Event`` and ``Todo`` build on this methods, by appending -their ``name`` to the returned tuple: +their ``summary`` to the returned tuple: :: >>> e11 = ics.Event(timespan=t1) >>> e11.cmp_tuple() (datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal()), datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal()), '') - >>> e12 = ics.Event(timespan=t1, name="An Event") + >>> e12 = ics.Event(timespan=t1, summary="An Event") >>> e12.cmp_tuple() (datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal()), datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal()), 'An Event') @@ -163,7 +163,7 @@ parameter is set: True >>> ics.Event(timespan=t1) < ics.Event(timespan=t2) True - >>> ics.Event(timespan=t2) < ics.Event(timespan=t2, name="Event Name") + >>> ics.Event(timespan=t2) < ics.Event(timespan=t2, summary="Event Name") True The functions ``__gt__``, ``__le__``, ``__ge__`` all behave similarly by diff --git a/ics/event.py b/ics/event.py index 21538139..c704ccdf 100644 --- a/ics/event.py +++ b/ics/event.py @@ -22,7 +22,7 @@ @attr.s(eq=True, order=False) class CalendarEntryAttrs(Component): _timespan: Timespan = attr.ib(validator=instance_of(Timespan), metadata=ics_attr_meta(converter=TimespanConverter)) - name: Optional[str] = attr.ib(default=None) # TODO name -> summary + summary: Optional[str] = attr.ib(default=None) uid: str = attr.ib(factory=uid_gen) description: Optional[str] = attr.ib(default=None) @@ -148,15 +148,15 @@ def timespan(self) -> Timespan: def __repr__(self) -> str: name = [self.__class__.__name__] - if self.name: - name.append("'%s'" % self.name) + if self.summary: + name.append("'%s'" % self.summary) prefix, _, suffix = self._timespan.get_str_segments() return "<%s>" % (" ".join(prefix + name + suffix)) #################################################################################################################### def cmp_tuple(self) -> Tuple[datetime, datetime, str]: - return (*self.timespan.cmp_tuple(), self.name or "") + return (*self.timespan.cmp_tuple(), self.summary or "") def __lt__(self, other: Any) -> bool: """self < other""" @@ -237,7 +237,7 @@ class Event(EventAttrs): def __init__( self, - name: str = None, + summary: str = None, begin: DatetimeLike = None, end: DatetimeLike = None, duration: TimedeltaLike = None, @@ -253,4 +253,4 @@ def __init__( if (begin is not None or end is not None or duration is not None) and "timespan" in kwargs: raise ValueError("can't specify explicit timespan together with any of begin, end or duration") kwargs.setdefault("timespan", EventTimespan(ensure_datetime(begin), ensure_datetime(end), ensure_timedelta(duration))) - super(Event, self).__init__(kwargs.pop("timespan"), name, *args, **kwargs) + super(Event, self).__init__(kwargs.pop("timespan"), summary, *args, **kwargs) diff --git a/ics/icalendar.py b/ics/icalendar.py index 465074ff..a3a35f02 100644 --- a/ics/icalendar.py +++ b/ics/icalendar.py @@ -101,7 +101,7 @@ def __iter__(self) -> Iterable[str]: Example: Can be used to write calendar to a file: - >>> c = Calendar(); c.events.append(Event(name="My cool event")) + >>> c = Calendar(); c.events.append(Event(summary="My cool event")) >>> open('my.ics', 'w').writelines(c) """ return iter(str(self).splitlines(keepends=True)) diff --git a/tests/event.py b/tests/event.py index 8c131ea6..fee4b131 100644 --- a/tests/event.py +++ b/tests/event.py @@ -4,10 +4,10 @@ import pytest from dateutil.tz import UTC as tzutc +from ics.grammar.parse import Container from ics.attendee import Attendee, Organizer from ics.event import Event -from ics.grammar.parse import Container from ics.icalendar import Calendar from .fixture import (cal12, cal13, cal15, cal16, cal17, cal18, cal19, cal19bis, cal20, cal32, cal33_1, cal33_2, cal33_3, @@ -15,10 +15,10 @@ CRLF = "\r\n" -EVENT_A = Event(name="a") -EVENT_B = Event(name="b") -EVENT_M = Event(name="m") -EVENT_Z = Event(name="z") +EVENT_A = Event(summary="a") +EVENT_B = Event(summary="b") +EVENT_M = Event(summary="m") +EVENT_Z = Event(summary="z") EVENT_A.created = EVENT_B.created = EVENT_M.created = EVENT_Z.created = dt.now() @@ -71,7 +71,7 @@ def test_geo_output(self): def test_init_duration_end(self): with self.assertRaises(ValueError): - Event(name="plop", begin=dt.fromtimestamp(0), end=dt.fromtimestamp(10), duration=td(1)) + Event(summary="plop", begin=dt.fromtimestamp(0), end=dt.fromtimestamp(10), duration=td(1)) def test_end_before_begin(self): e = Event(begin=dt(2013, 10, 10)) @@ -87,17 +87,17 @@ def test_plain_repr(self): self.assertEqual("", repr(Event())) def test_all_day_repr(self): - e = Event(name='plop', begin=dt(1999, 10, 10)) + e = Event(summary='plop', begin=dt(1999, 10, 10)) e.make_all_day() self.assertEqual("", repr(e)) self.assertEqual(dt(1999, 10, 11), e.end) def test_name_repr(self): - e = Event(name='plop') + e = Event(summary='plop') self.assertEqual("", repr(e)) def test_repr(self): - e = Event(name='plop', begin=dt(1999, 10, 10)) + e = Event(summary='plop', begin=dt(1999, 10, 10)) self.assertEqual("", repr(e)) def test_init(self): @@ -262,19 +262,19 @@ def test_cmp_by_end_time(self): def test_unescape_summary(self): c = Calendar(cal15) e = next(iter(c.events)) - self.assertEqual(e.name, "Hello, \n World; This is a backslash : \\ and another new \n line") + self.assertEqual(e.summary, "Hello, \n World; This is a backslash : \\ and another new \n line") def test_unescapte_texts(self): c = Calendar(cal17) e = next(iter(c.events)) - self.assertEqual(e.name, "Some special ; chars") + self.assertEqual(e.summary, "Some special ; chars") self.assertEqual(e.location, "In, every text field") self.assertEqual(e.description, "Yes, all of them;") def test_escape_output(self): e = Event() - e.name = "Hello, with \\ special; chars and \n newlines" + e.summary = "Hello, with \\ special; chars and \n newlines" e.location = "Here; too" e.description = "Every\nwhere ! Yes, yes !" e.dtstamp = dt(2013, 1, 1) @@ -298,7 +298,7 @@ def test_url_input(self): def test_url_output(self): URL = "http://example.com/pub/calendars/jsmith/mytime.ics" - e = Event(name="Name", url=URL) + e = Event(summary="Name", url=URL) self.assertIn("URL:" + URL, str(e).splitlines()) def test_status_input(self): @@ -308,7 +308,7 @@ def test_status_input(self): def test_status_output(self): STATUS = "CONFIRMED" - e = Event(name="Name", status=STATUS) + e = Event(summary="Name", status=STATUS) self.assertIn("STATUS:" + STATUS, str(e).splitlines()) def test_category_input(self): @@ -320,7 +320,7 @@ def test_category_input(self): def test_category_output(self): cat = "Simple category" - e = Event(name="Name", categories={cat}) + e = Event(summary="Name", categories={cat}) self.assertIn("CATEGORIES:" + cat, str(e).splitlines()) def test_all_day_with_end(self): @@ -380,75 +380,75 @@ def test_default_transparent_input(self): self.assertEqual(e.transparent, None) def test_default_transparent_output(self): - e = Event(name="Name") + e = Event(summary="Name") self.assertNotIn("TRANSP:OPAQUE", str(e).splitlines()) def test_transparent_output(self): - e = Event(name="Name", transparent=True) + e = Event(summary="Name", transparent=True) self.assertIn("TRANSP:TRANSPARENT", str(e).splitlines()) - e = Event(name="Name", transparent=False) + e = Event(summary="Name", transparent=False) self.assertIn("TRANSP:OPAQUE", str(e).splitlines()) def test_includes_disjoined(self): # disjoined events - event_a = Event(name='Test #1', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=20)) - event_b = Event(name='Test #2', begin=dt(2016, 6, 10, 20, 50), duration=td(minutes=20)) + event_a = Event(summary='Test #1', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=20)) + event_b = Event(summary='Test #2', begin=dt(2016, 6, 10, 20, 50), duration=td(minutes=20)) assert not event_a.includes(event_b) assert not event_b.includes(event_a) def test_includes_intersected(self): # intersected events - event_a = Event(name='Test #1', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) - event_b = Event(name='Test #2', begin=dt(2016, 6, 10, 20, 30), duration=td(minutes=30)) + event_a = Event(summary='Test #1', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) + event_b = Event(summary='Test #2', begin=dt(2016, 6, 10, 20, 30), duration=td(minutes=30)) assert not event_a.includes(event_b) assert not event_b.includes(event_a) - event_a = Event(name='Test #1', begin=dt(2016, 6, 10, 20, 30), duration=td(minutes=30)) - event_b = Event(name='Test #2', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) + event_a = Event(summary='Test #1', begin=dt(2016, 6, 10, 20, 30), duration=td(minutes=30)) + event_b = Event(summary='Test #2', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) assert not event_a.includes(event_b) assert not event_b.includes(event_a) def test_includes_included(self): # included events - event_a = Event(name='Test #1', begin=dt(2016, 6, 10, 20, 00), duration=td(minutes=60)) - event_b = Event(name='Test #2', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) + event_a = Event(summary='Test #1', begin=dt(2016, 6, 10, 20, 00), duration=td(minutes=60)) + event_b = Event(summary='Test #2', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) assert event_a.includes(event_b) assert not event_b.includes(event_a) - event_a = Event(name='Test #1', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) - event_b = Event(name='Test #2', begin=dt(2016, 6, 10, 20, 00), duration=td(minutes=60)) + event_a = Event(summary='Test #1', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) + event_b = Event(summary='Test #2', begin=dt(2016, 6, 10, 20, 00), duration=td(minutes=60)) assert not event_a.includes(event_b) assert event_b.includes(event_a) def test_intersects_disjoined(self): # disjoined events - event_a = Event(name='Test #1', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=20)) - event_b = Event(name='Test #2', begin=dt(2016, 6, 10, 20, 50), duration=td(minutes=20)) + event_a = Event(summary='Test #1', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=20)) + event_b = Event(summary='Test #2', begin=dt(2016, 6, 10, 20, 50), duration=td(minutes=20)) assert not event_a.intersects(event_b) assert not event_b.intersects(event_a) def test_intersects_intersected(self): # intersected events - event_a = Event(name='Test #1', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) - event_b = Event(name='Test #2', begin=dt(2016, 6, 10, 20, 30), duration=td(minutes=30)) + event_a = Event(summary='Test #1', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) + event_b = Event(summary='Test #2', begin=dt(2016, 6, 10, 20, 30), duration=td(minutes=30)) assert event_a.intersects(event_b) assert event_b.intersects(event_a) - event_a = Event(name='Test #1', begin=dt(2016, 6, 10, 20, 30), duration=td(minutes=30)) - event_b = Event(name='Test #2', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) + event_a = Event(summary='Test #1', begin=dt(2016, 6, 10, 20, 30), duration=td(minutes=30)) + event_b = Event(summary='Test #2', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) assert event_a.intersects(event_b) assert event_b.intersects(event_a) def test_intersects_included(self): # included events - event_a = Event(name='Test #1', begin=dt(2016, 6, 10, 20, 00), duration=td(minutes=60)) - event_b = Event(name='Test #2', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) + event_a = Event(summary='Test #1', begin=dt(2016, 6, 10, 20, 00), duration=td(minutes=60)) + event_b = Event(summary='Test #2', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) assert event_a.intersects(event_b) assert event_b.intersects(event_a) - event_a = Event(name='Test #1', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) - event_b = Event(name='Test #2', begin=dt(2016, 6, 10, 20, 00), duration=td(minutes=60)) + event_a = Event(summary='Test #1', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) + event_b = Event(summary='Test #2', begin=dt(2016, 6, 10, 20, 00), duration=td(minutes=60)) assert event_a.intersects(event_b) assert event_b.intersects(event_a) @@ -522,27 +522,27 @@ def test_classification_input(self): self.assertEqual('x-name', e.classification) def test_classification_output(self): - e = Event(name="Name") + e = Event(summary="Name") self.assertNotIn("CLASS:PUBLIC", str(e).splitlines()) - e = Event(name="Name", classification='PUBLIC') + e = Event(summary="Name", classification='PUBLIC') self.assertIn("CLASS:PUBLIC", str(e).splitlines()) - e = Event(name="Name", classification='PRIVATE') + e = Event(summary="Name", classification='PRIVATE') self.assertIn("CLASS:PRIVATE", str(e).splitlines()) - e = Event(name="Name", classification='CONFIDENTIAL') + e = Event(summary="Name", classification='CONFIDENTIAL') self.assertIn("CLASS:CONFIDENTIAL", str(e).splitlines()) - e = Event(name="Name", classification='iana-token') + e = Event(summary="Name", classification='iana-token') self.assertIn("CLASS:iana-token", str(e).splitlines()) - e = Event(name="Name", classification='x-name') + e = Event(summary="Name", classification='x-name') self.assertIn("CLASS:x-name", str(e).splitlines()) def test_classification_bool(self): with pytest.raises(TypeError): - Event(name="Name", classification=True) + Event(summary="Name", classification=True) def test_last_modified(self): c = Calendar(cal18) @@ -550,7 +550,7 @@ def test_last_modified(self): self.assertEqual(dt(2015, 11, 13, 00, 48, 9, tzinfo=tzutc), e.last_modified) def equality(self): - ev1 = Event(begin=dt(2018, 6, 29, 5), end=dt(2018, 6, 29, 7), name="my name") + ev1 = Event(summary="my name", begin=dt(2018, 6, 29, 5), end=dt(2018, 6, 29, 7)) ev2 = ev1.clone() assert ev1 == ev2 diff --git a/tests/todo.py b/tests/todo.py index 91c24c9b..8becde5b 100644 --- a/tests/todo.py +++ b/tests/todo.py @@ -2,9 +2,9 @@ from datetime import datetime, datetime as dt, timedelta, timezone from dateutil.tz import UTC as dateutil_tzutc - from ics.alarm.display import DisplayAlarm from ics.grammar.parse import Container + from ics.icalendar import Calendar from ics.todo import Todo from .fixture import cal27, cal28, cal29, cal30, cal31 @@ -28,7 +28,7 @@ def test_init(self): self.assertIsNone(t.location) self.assertIsNone(t.percent) self.assertIsNone(t.priority) - self.assertIsNone(t.name) + self.assertIsNone(t.summary) self.assertIsNone(t.url) self.assertIsNone(t.status) self.assertEqual(t.extra, Container(name='VTODO')) @@ -58,7 +58,7 @@ def test_init_non_exclusive_arguments(self): self.assertEqual(t.created, created) self.assertEqual(t.description, 'description') self.assertEqual(t.location, 'location') - self.assertEqual(t.name, 'name') + self.assertEqual(t.summary, 'name') self.assertEqual(t.url, 'url') self.assertEqual(t.alarms, alarms) @@ -276,7 +276,7 @@ def test_extract(self): self.assertEqual(t.location, 'Earth') self.assertEqual(t.percent, 0) self.assertEqual(t.priority, 0) - self.assertEqual(t.name, 'Name') + self.assertEqual(t.summary, 'Name') self.assertEqual(t.url, 'https://www.example.com/cal.php/todo.ics') self.assertEqual(t.duration, timedelta(minutes=10)) self.assertEqual(len(t.alarms), 1) @@ -335,7 +335,7 @@ def test_output_due(self): def test_unescape_texts(self): c = Calendar(cal31) t = next(iter(c.todos)) - self.assertEqual(t.name, "Hello, \n World; This is a backslash : \\ and another new \n line") + self.assertEqual(t.summary, "Hello, \n World; This is a backslash : \\ and another new \n line") self.assertEqual(t.location, "In, every text field") self.assertEqual(t.description, "Yes, all of them;") @@ -343,7 +343,7 @@ def test_escape_output(self): dtstamp = datetime(2018, 2, 19, 21, 00, tzinfo=datetime_tzutc) t = Todo(dtstamp=dtstamp, uid='Uid') - t.name = "Hello, with \\ special; chars and \n newlines" + t.summary = "Hello, with \\ special; chars and \n newlines" t.location = "Here; too" t.description = "Every\nwhere ! Yes, yes !" From 537823d6d1d9eb14c9a9af4a2121cbfe8d4e3b74 Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Sat, 4 Apr 2020 23:44:41 +0200 Subject: [PATCH 06/43] make mypy happy with ExtraParams and EmptyParams and their defaults --- ics/converter/base.py | 17 ++++++++++------- ics/converter/component.py | 16 ++++++++-------- ics/converter/special.py | 20 +++++++++---------- ics/converter/timespan.py | 10 +++++----- ics/converter/value.py | 14 +++++++------- ics/grammar/__init__.py | 8 ++++---- ics/types.py | 9 ++++++--- ics/valuetype/base.py | 8 ++++---- ics/valuetype/datetime.py | 39 ++++++++++++++++++++------------------ ics/valuetype/generic.py | 32 +++++++++++++++---------------- ics/valuetype/special.py | 8 ++++---- 11 files changed, 95 insertions(+), 86 deletions(-) diff --git a/ics/converter/base.py b/ics/converter/base.py index 6d98efeb..c18f275b 100644 --- a/ics/converter/base.py +++ b/ics/converter/base.py @@ -5,7 +5,7 @@ import attr from ics.grammar import Container -from ics.types import ContainerItem, ExtraParams +from ics.types import ContainerItem, ContextDict, ExtraParams if TYPE_CHECKING: from ics.component import Component @@ -24,7 +24,7 @@ def filter_ics_names(self) -> List[str]: pass @abc.abstractmethod - def populate(self, component: "Component", item: ContainerItem, context: Dict) -> bool: + def populate(self, component: "Component", item: ContainerItem, context: ContextDict) -> bool: """ :param context: :param component: @@ -33,11 +33,11 @@ def populate(self, component: "Component", item: ContainerItem, context: Dict) - """ pass - def finalize(self, component: "Component", context: Dict): + def finalize(self, component: "Component", context: ContextDict): pass @abc.abstractmethod - def serialize(self, component: "Component", output: Container, context: Dict): + def serialize(self, component: "Component", output: Container, context: ContextDict): pass @@ -68,7 +68,7 @@ def __attrs_post_init__(self): if key == "self" or key.startswith("__"): continue object.__setattr__(self, key, value) - def _check_component(self, component: "Component", context: Dict): + def _check_component(self, component: "Component", context: ContextDict): if context[(self, "current_component")] is None: context[(self, "current_component")] = component context[(self, "current_value_count")] = 0 @@ -76,7 +76,7 @@ def _check_component(self, component: "Component", context: Dict): if context[(self, "current_component")] is not component: raise ValueError("must call finalize before call to populate with another component") - def finalize(self, component: "Component", context: Dict): + def finalize(self, component: "Component", context: ContextDict): context[(self, "current_component")] = None context[(self, "current_value_count")] = 0 @@ -108,7 +108,10 @@ def set_or_append_extra_params(self, component: "Component", value: ExtraParams, component.extra_params[name] = value def get_extra_params(self, component: "Component", name: Optional[str] = None) -> Union[ExtraParams, List[ExtraParams]]: - default: Union[ExtraParams, List[ExtraParams]] = list() if self.multi_value_type else dict() + if self.multi_value_type: + default: Union[ExtraParams, List[ExtraParams]] = cast(List[ExtraParams], list()) + else: + default = ExtraParams(dict()) name = name or self.attribute.name return component.extra_params.get(name, default) diff --git a/ics/converter/component.py b/ics/converter/component.py index 886b5d2a..1baeadba 100644 --- a/ics/converter/component.py +++ b/ics/converter/component.py @@ -6,7 +6,7 @@ from ics.converter.base import AttributeConverter, GenericConverter from ics.grammar import Container -from ics.types import ContainerItem +from ics.types import ContainerItem, ContextDict if TYPE_CHECKING: from ics.component import Component @@ -47,16 +47,16 @@ def __attrs_post_init__(self): def __call__(self, attribute: Attribute): return self.converter_class(attribute, self) - def load_instance(self, container: Container, context: Optional[Dict] = None): + def load_instance(self, container: Container, context: Optional[ContextDict] = None): instance = self.component_type() self.populate_instance(instance, container, context) return instance - def populate_instance(self, instance: "Component", container: Container, context: Optional[Dict] = None): + def populate_instance(self, instance: "Component", container: Container, context: Optional[ContextDict] = None): if container.name != self.container_name: raise ValueError("container isn't an {}".format(self.container_name)) if not context: - context = defaultdict(lambda: None) + context = ContextDict(defaultdict(lambda: None)) for line in container: consumed = False @@ -69,9 +69,9 @@ def populate_instance(self, instance: "Component", container: Container, context for conv in self.converters: conv.finalize(instance, context) - def serialize_toplevel(self, component: "Component", context: Optional[Dict] = None): + def serialize_toplevel(self, component: "Component", context: Optional[ContextDict] = None): if not context: - context = defaultdict(lambda: None) + context = ContextDict(defaultdict(lambda: None)) container = Container(self.container_name) for conv in self.converters: conv.serialize(component, container, context) @@ -87,13 +87,13 @@ class ComponentConverter(AttributeConverter): def filter_ics_names(self) -> List[str]: return [self.meta.container_name] - def populate(self, component: "Component", item: ContainerItem, context: Dict) -> bool: + def populate(self, component: "Component", item: ContainerItem, context: ContextDict) -> bool: assert isinstance(item, Container) self._check_component(component, context) self.set_or_append_value(component, self.meta.load_instance(item, context)) return True - def serialize(self, parent: "Component", output: Container, context: Dict): + def serialize(self, parent: "Component", output: Container, context: ContextDict): self._check_component(parent, context) extras = self.get_extra_params(parent) if extras: diff --git a/ics/converter/special.py b/ics/converter/special.py index 8d46cd36..e36be381 100644 --- a/ics/converter/special.py +++ b/ics/converter/special.py @@ -1,6 +1,6 @@ from datetime import tzinfo from io import StringIO -from typing import Dict, List, TYPE_CHECKING +from typing import List, TYPE_CHECKING from dateutil.rrule import rruleset from dateutil.tz import tzical @@ -9,7 +9,7 @@ from ics.converter.base import AttributeConverter from ics.converter.component import ComponentConverter from ics.grammar import Container, ContentLine -from ics.types import ContainerItem +from ics.types import ContainerItem, ContextDict if TYPE_CHECKING: from ics.component import Component @@ -20,7 +20,7 @@ class TimezoneConverter(AttributeConverter): def filter_ics_names(self) -> List[str]: return ["VTIMEZONE"] - def populate(self, component: "Component", item: ContainerItem, context: Dict) -> bool: + def populate(self, component: "Component", item: ContainerItem, context: ContextDict) -> bool: assert isinstance(item, Container) self._check_component(component, context) @@ -39,7 +39,7 @@ def populate(self, component: "Component", item: ContainerItem, context: Dict) - print("got timezone", timezones.keys(), timezones.get()) return True - def serialize(self, component: "Component", output: Container, context: Dict): + def serialize(self, component: "Component", output: Container, context: ContextDict): raise NotImplementedError("Timezones can't be serialized") @@ -54,17 +54,17 @@ class RecurrenceConverter(AttributeConverter): def filter_ics_names(self) -> List[str]: return ["RRULE", "RDATE", "EXRULE", "EXDATE", "DTSTART"] - def populate(self, component: "Component", item: ContainerItem, context: Dict) -> bool: + def populate(self, component: "Component", item: ContainerItem, context: ContextDict) -> bool: assert isinstance(item, ContentLine) self._check_component(component, context) # self.lines.append(item) return False - def finalize(self, component: "Component", context: Dict): + def finalize(self, component: "Component", context: ContextDict): self._check_component(component, context) # rrulestr("\r\n".join(self.lines), tzinfos={}, compatible=True) - def serialize(self, component: "Component", output: Container, context: Dict): + def serialize(self, component: "Component", output: Container, context: ContextDict): pass # value = rruleset() # for rrule in value._rrule: @@ -87,12 +87,12 @@ class PersonConverter(AttributeConverter): def filter_ics_names(self) -> List[str]: return [] - def populate(self, component: "Component", item: ContainerItem, context: Dict) -> bool: + def populate(self, component: "Component", item: ContainerItem, context: ContextDict) -> bool: assert isinstance(item, ContentLine) self._check_component(component, context) return False - def serialize(self, component: "Component", output: Container, context: Dict): + def serialize(self, component: "Component", output: Container, context: ContextDict): pass @@ -102,7 +102,7 @@ def serialize(self, component: "Component", output: Container, context: Dict): class AlarmConverter(ComponentConverter): - def populate(self, component: "Component", item: ContainerItem, context: Dict) -> bool: + def populate(self, component: "Component", item: ContainerItem, context: ContextDict) -> bool: # TODO handle trigger: Union[timedelta, datetime, None] before duration assert isinstance(item, Container) self._check_component(component, context) diff --git a/ics/converter/timespan.py b/ics/converter/timespan.py index 10b70416..74592ed8 100644 --- a/ics/converter/timespan.py +++ b/ics/converter/timespan.py @@ -1,9 +1,9 @@ -from typing import Dict, List, TYPE_CHECKING, cast +from typing import List, TYPE_CHECKING, cast from ics.converter.base import AttributeConverter from ics.grammar import Container, ContentLine from ics.timespan import EventTimespan, Timespan, TodoTimespan -from ics.types import ContainerItem, ExtraParams, copy_extra_params +from ics.types import ContainerItem, ContextDict, ExtraParams, copy_extra_params from ics.utils import ensure_datetime from ics.valuetype.datetime import DateConverter, DatetimeConverter, DurationConverter @@ -29,7 +29,7 @@ def default_priority(self) -> int: def filter_ics_names(self) -> List[str]: return ["DTSTART", "DTEND", "DUE", "DURATION"] - def populate(self, component: "Component", item: ContainerItem, context: Dict) -> bool: + def populate(self, component: "Component", item: ContainerItem, context: ContextDict) -> bool: assert isinstance(item, ContentLine) self._check_component(component, context) @@ -76,7 +76,7 @@ def populate(self, component: "Component", item: ContainerItem, context: Dict) - return True - def finalize(self, component: "Component", context: Dict): + def finalize(self, component: "Component", context: ContextDict): self._check_component(component, context) # missing values will be reported by the Timespan validator timespan = self.value_type( @@ -91,7 +91,7 @@ def finalize(self, component: "Component", context: Dict): for key in CONTEXT_KEYS: context.pop(key, None) - def serialize(self, component: "Component", output: Container, context: Dict): + def serialize(self, component: "Component", output: Container, context: ContextDict): self._check_component(component, context) value: Timespan = self.get_value(component) if value.is_all_day(): diff --git a/ics/converter/value.py b/ics/converter/value.py index 8245944d..8731555c 100644 --- a/ics/converter/value.py +++ b/ics/converter/value.py @@ -1,10 +1,10 @@ -from typing import Any, Dict, List, TYPE_CHECKING, Tuple, cast +from typing import Any, List, TYPE_CHECKING, Tuple, cast import attr from ics.converter.base import AttributeConverter from ics.grammar import Container, ContentLine -from ics.types import ContainerItem, ExtraParams, copy_extra_params +from ics.types import ContainerItem, ContextDict, ExtraParams, copy_extra_params from ics.valuetype.base import ValueConverter if TYPE_CHECKING: @@ -35,7 +35,7 @@ def ics_name(self) -> str: name = self.attribute.name.upper().replace("_", "-").strip("-") return name - def __parse_value(self, line: "ContentLine", value: str, context: Dict) -> Tuple[ExtraParams, ValueConverter]: + def __parse_value(self, line: "ContentLine", value: str, context: ContextDict) -> Tuple[ExtraParams, ValueConverter]: params = copy_extra_params(line.params) value_type = params.pop("VALUE", None) if value_type: @@ -53,7 +53,7 @@ def __parse_value(self, line: "ContentLine", value: str, context: Dict) -> Tuple # TODO make storing/writing extra values/params configurably optional, but warn when information is lost - def populate(self, component: "Component", item: ContainerItem, context: Dict) -> bool: + def populate(self, component: "Component", item: ContainerItem, context: ContextDict) -> bool: assert isinstance(item, ContentLine) self._check_component(component, context) if self.is_multi_value: @@ -75,7 +75,7 @@ def populate(self, component: "Component", item: ContainerItem, context: Dict) - self.set_or_append_value(component, parsed) return True - def finalize(self, component: "Component", context: Dict): + def finalize(self, component: "Component", context: ContextDict): self._check_component(component, context) if self.is_required and context[(self, "current_value_count")] < 1: raise ValueError("attribute %s is required but got no value" % self.ics_name) @@ -90,7 +90,7 @@ def __find_value_converter(self, params: ExtraParams, value: Any) -> ValueConver else: raise ValueError("can't convert %s with %s" % (value, self)) - def serialize(self, component: "Component", output: Container, context: Dict): + def serialize(self, component: "Component", output: Container, context: ContextDict): if self.is_multi_value: self.__serialize_multi(component, output, context) else: @@ -101,7 +101,7 @@ def serialize(self, component: "Component", output: Container, context: Dict): serialized = converter.serialize(value, params, context) output.append(ContentLine(name=self.ics_name, params=params, value=serialized)) - def __serialize_multi(self, component: "Component", output: "Container", context: Dict): + def __serialize_multi(self, component: "Component", output: "Container", context: ContextDict): extra_params = cast(List[ExtraParams], self.get_extra_params(component)) values = self.get_value_list(component) if len(extra_params) != len(values): diff --git a/ics/grammar/__init__.py b/ics/grammar/__init__.py index 95803d5f..34f64472 100644 --- a/ics/grammar/__init__.py +++ b/ics/grammar/__init__.py @@ -1,13 +1,13 @@ import collections import re from pathlib import Path -from typing import Dict, List +from typing import List import attr import tatsu from tatsu.exceptions import FailedToken -from ics.types import ContainerItem, RuntimeAttrValidation +from ics.types import ContainerItem, ExtraParams, RuntimeAttrValidation grammar_path = Path(__file__).parent.joinpath('contentline.ebnf') @@ -32,7 +32,7 @@ class ContentLine(RuntimeAttrValidation): """ name: str = attr.ib(converter=str.upper) # type: ignore - params: Dict[str, List[str]] = attr.ib(factory=dict) + params: ExtraParams = attr.ib(factory=lambda: ExtraParams(dict())) value: str = attr.ib(default="") # TODO ensure (parameter) value escaping and name normalization @@ -73,7 +73,7 @@ def parse(cls, line): def interpret_ast(cls, ast): name = ''.join(ast['name']) value = ''.join(ast['value']) - params = {} + params = ExtraParams(dict()) for param_ast in ast.get('params', []): param_name = ''.join(param_ast["name"]) param_values = [''.join(x) for x in param_ast["values_"]] diff --git a/ics/types.py b/ics/types.py index 31687ffb..58ffebab 100644 --- a/ics/types.py +++ b/ics/types.py @@ -1,5 +1,5 @@ from datetime import date, datetime, timedelta -from typing import Any, Dict, Iterator, List, Mapping, NewType, Optional, TYPE_CHECKING, Tuple, Union, overload +from typing import Any, Dict, Iterator, List, Mapping, NewType, Optional, TYPE_CHECKING, Tuple, Union, cast, overload import attr @@ -31,7 +31,7 @@ "RuntimeAttrValidation", - "EmptyDict", "EmptyDictType", "ExtraParams", "copy_extra_params", + "EmptyDict", "ExtraParams", "EmptyParams", "ContextDict", "EmptyContext", "copy_extra_params", ] ContainerItem = Union["ContentLine", "Container"] @@ -120,7 +120,10 @@ def __iter__(self) -> Iterator[Any]: EmptyDict = EmptyDictType() -ExtraParams = NewType('ExtraParams', Dict[str, List[str]]) +ExtraParams = NewType("ExtraParams", Dict[str, List[str]]) +EmptyParams = cast("ExtraParams", EmptyDictType()) +ContextDict = NewType("ContextDict", Dict[Any, Any]) +EmptyContext = cast("ContextDict", EmptyDictType()) def copy_extra_params(old: Optional[ExtraParams]) -> ExtraParams: diff --git a/ics/valuetype/base.py b/ics/valuetype/base.py index e38b5df5..ae344195 100644 --- a/ics/valuetype/base.py +++ b/ics/valuetype/base.py @@ -2,7 +2,7 @@ import inspect from typing import Dict, Generic, Type, TypeVar -from ics.types import EmptyDict, ExtraParams +from ics.types import ContextDict, EmptyContext, EmptyParams, ExtraParams T = TypeVar('T') @@ -10,7 +10,7 @@ class ValueConverter(abc.ABC, Generic[T]): BY_NAME: Dict[str, "ValueConverter"] = {} BY_TYPE: Dict[Type, "ValueConverter"] = {} - INST: "ValueConverter[T]" + INST: "ValueConverter" def __init_subclass__(cls) -> None: super().__init_subclass__() @@ -30,11 +30,11 @@ def python_type(self) -> Type[T]: pass @abc.abstractmethod - def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> T: + def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> T: pass @abc.abstractmethod - def serialize(self, value: T, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: + def serialize(self, value: T, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: pass def __str__(self): diff --git a/ics/valuetype/datetime.py b/ics/valuetype/datetime.py index 40968bcd..c49dedd1 100644 --- a/ics/valuetype/datetime.py +++ b/ics/valuetype/datetime.py @@ -1,14 +1,14 @@ import re import warnings from datetime import date, datetime, time, timedelta -from typing import Dict, List, Optional, Type, cast +from typing import List, Optional, Type, cast from dateutil.tz import UTC as dateutil_tzutc, gettz, tzoffset as UTCOffset from ics.timespan import Timespan -from ics.types import EmptyDict, ExtraParams, copy_extra_params +from ics.types import ContextDict, EmptyContext, EmptyParams, ExtraParams, copy_extra_params from ics.utils import is_utc -from ics.valuetype.base import T, ValueConverter +from ics.valuetype.base import ValueConverter class DatetimeConverterMixin(object): @@ -18,25 +18,28 @@ class DatetimeConverterMixin(object): } CONTEXT_KEY_AVAILABLE_TZ = "DatetimeAvailableTimezones" - def _serialize_dt(self, value: datetime, params: ExtraParams, context: Dict, + def _serialize_dt(self, value: datetime, params: ExtraParams, context: ContextDict, utc_fmt="%Y%m%dT%H%M%SZ", nonutc_fmt="%Y%m%dT%H%M%S") -> str: if is_utc(value): return value.strftime(utc_fmt) else: if value.tzinfo is not None: tzname = value.tzinfo.tzname(value) + if not tzname: + # TODO generate unique identifier as name + raise ValueError("could not generate name for tzinfo %s" % value.tzinfo) params["TZID"] = [tzname] available_tz = context.setdefault(self.CONTEXT_KEY_AVAILABLE_TZ, {}) available_tz.setdefault(tzname, value.tzinfo) return value.strftime(nonutc_fmt) - def _parse_dt(self, value: str, params: ExtraParams, context: Dict, + def _parse_dt(self, value: str, params: ExtraParams, context: ContextDict, warn_no_avail_tz=True) -> datetime: param_tz_list: Optional[List[str]] = params.pop("TZID", None) # we remove the TZID from context if param_tz_list: if len(param_tz_list) > 1: raise ValueError("got multiple TZIDs") - param_tz = param_tz_list[0] + param_tz: Optional[str] = param_tz_list[0] else: param_tz = None available_tz = context.get(self.CONTEXT_KEY_AVAILABLE_TZ, None) @@ -82,10 +85,10 @@ def ics_type(self) -> str: def python_type(self) -> Type[datetime]: return datetime - def serialize(self, value: T, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: + def serialize(self, value: datetime, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: return self._serialize_dt(value, params, context) - def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> datetime: + def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> datetime: return self._parse_dt(value, params, context) @@ -98,10 +101,10 @@ def ics_type(self) -> str: def python_type(self) -> Type[date]: return date - def serialize(self, value, params: ExtraParams = EmptyDict, context: Dict = EmptyDict): + def serialize(self, value, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext): return value.strftime("%Y%m%d") - def parse(self, value, params: ExtraParams = EmptyDict, context: Dict = EmptyDict): + def parse(self, value, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext): return self._parse_dt(value, params, context, warn_no_avail_tz=False).date() @@ -120,10 +123,10 @@ def ics_type(self) -> str: def python_type(self) -> Type[time]: return time - def serialize(self, value, params: ExtraParams = EmptyDict, context: Dict = EmptyDict): + def serialize(self, value, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext): return self._serialize_dt(value, params, context, utc_fmt="%H%M%SZ", nonutc_fmt="%H%M%S") - def parse(self, value, params: ExtraParams = EmptyDict, context: Dict = EmptyDict): + def parse(self, value, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext): return self._parse_dt(value, params, context).timetz() @@ -136,7 +139,7 @@ def ics_type(self) -> str: def python_type(self) -> Type[UTCOffset]: return UTCOffset - def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> UTCOffset: + def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> UTCOffset: match = re.fullmatch(r"(?P\+|-|)(?P[0-9]{2})(?P[0-9]{2})(?P[0-9]{2})?", value) if not match: raise ValueError("value '%s' is not a valid UTCOffset") @@ -147,7 +150,7 @@ def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = Emp td *= -1 return UTCOffset(value, td) - def serialize(self, value: UTCOffset, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: + def serialize(self, value: UTCOffset, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: offset = value.utcoffset(None) assert offset is not None seconds = offset.seconds @@ -180,7 +183,7 @@ def ics_type(self) -> str: def python_type(self) -> Type[timedelta]: return timedelta - def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> timedelta: + def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> timedelta: DAYS = {'D': 1, 'W': 7} SECS = {'S': 1, 'M': 60, 'H': 3600} @@ -215,7 +218,7 @@ def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = Emp i = j + 1 return timedelta(sign * days, sign * secs) - def serialize(self, value: timedelta, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: + def serialize(self, value: timedelta, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: ONE_DAY_IN_SECS = 3600 * 24 total = abs(int(value.total_seconds())) days = total // ONE_DAY_IN_SECS @@ -253,7 +256,7 @@ def ics_type(self) -> str: def python_type(self) -> Type[Timespan]: return Timespan - def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict): + def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext): start, sep, end = value.partition("/") if not sep: raise ValueError("PERIOD '%s' must contain the separator '/'") @@ -265,7 +268,7 @@ def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = Emp return Timespan(begin_time=self._parse_dt(start, params, context), end_time=self._parse_dt(end, end_params, context)) - def serialize(self, value: Timespan, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: + def serialize(self, value: Timespan, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: # note: there are no DATE to DATE / all-day periods begin = value.get_begin() if begin is None: diff --git a/ics/valuetype/generic.py b/ics/valuetype/generic.py index 7acf541b..b8d20da2 100644 --- a/ics/valuetype/generic.py +++ b/ics/valuetype/generic.py @@ -1,10 +1,10 @@ import base64 -from typing import Dict, Type +from typing import Type from urllib.parse import ParseResult as URL, urlparse from dateutil.rrule import rrule -from ics.types import EmptyDict, ExtraParams +from ics.types import ContextDict, EmptyContext, EmptyParams, ExtraParams from ics.valuetype.base import ValueConverter @@ -18,10 +18,10 @@ def ics_type(self) -> str: def python_type(self) -> Type[str]: return str - def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: + def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: return value - def serialize(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: + def serialize(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: return value @@ -35,10 +35,10 @@ def ics_type(self) -> str: def python_type(self) -> Type[bytes]: return bytes - def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> bytes: + def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> bytes: return base64.b64decode(value) - def serialize(self, value: bytes, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: + def serialize(self, value: bytes, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: return base64.b64encode(value).decode("ascii") @@ -55,7 +55,7 @@ def ics_type(self) -> str: def python_type(self) -> Type[bool]: return bool - def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> bool: + def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> bool: if value == "TRUE": return True elif value == "FALSE": @@ -73,7 +73,7 @@ def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = Emp else: raise ValueError("can't interpret '%s' as boolen" % value) - def serialize(self, value: bool, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: + def serialize(self, value: bool, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: if value: return "TRUE" else: @@ -90,10 +90,10 @@ def ics_type(self) -> str: def python_type(self) -> Type[int]: return int - def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> int: + def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> int: return int(value) - def serialize(self, value: int, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: + def serialize(self, value: int, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: return str(value) @@ -107,10 +107,10 @@ def ics_type(self) -> str: def python_type(self) -> Type[float]: return float - def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> float: + def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> float: return float(value) - def serialize(self, value: float, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: + def serialize(self, value: float, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: return str(value) @@ -124,11 +124,11 @@ def ics_type(self) -> str: def python_type(self) -> Type[rrule]: return rrule - def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> rrule: + def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> rrule: # this won't be called unless a class specifies an attribute with type: rrule raise NotImplementedError("parsing 'RECUR' is not yet supported") - def serialize(self, value: rrule, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: + def serialize(self, value: rrule, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: raise NotImplementedError("serializing 'RECUR' is not yet supported") @@ -142,10 +142,10 @@ def ics_type(self) -> str: def python_type(self) -> Type[URL]: return URL - def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> URL: + def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> URL: return urlparse(value) - def serialize(self, value: URL, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: + def serialize(self, value: URL, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: if isinstance(value, str): return value else: diff --git a/ics/valuetype/special.py b/ics/valuetype/special.py index ecbe1321..60698a59 100644 --- a/ics/valuetype/special.py +++ b/ics/valuetype/special.py @@ -1,7 +1,7 @@ -from typing import Dict, Type +from typing import Type from ics.geo import Geo -from ics.types import EmptyDict, ExtraParams +from ics.types import ContextDict, EmptyContext, EmptyParams, ExtraParams from ics.valuetype.base import ValueConverter @@ -15,11 +15,11 @@ def ics_type(self) -> str: def python_type(self) -> Type[Geo]: return Geo - def parse(self, value: str, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> Geo: + def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> Geo: latitude, sep, longitude = value.partition(";") if not sep: raise ValueError("geo must have two float values") return Geo(float(latitude), float(longitude)) - def serialize(self, value: Geo, params: ExtraParams = EmptyDict, context: Dict = EmptyDict) -> str: + def serialize(self, value: Geo, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: return "%f;%f" % value From 827088e72347ea011deef550961b24d3700678d0 Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Sat, 4 Apr 2020 23:59:10 +0200 Subject: [PATCH 07/43] warn when a modification of EmptyDictType is attempted --- ics/types.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/ics/types.py b/ics/types.py index 58ffebab..58ae29ba 100644 --- a/ics/types.py +++ b/ics/types.py @@ -1,5 +1,6 @@ +import warnings from datetime import date, datetime, timedelta -from typing import Any, Dict, Iterator, List, Mapping, NewType, Optional, TYPE_CHECKING, Tuple, Union, cast, overload +from typing import Any, Dict, Iterator, List, MutableMapping, NewType, Optional, TYPE_CHECKING, Tuple, Union, cast, overload import attr @@ -106,12 +107,20 @@ def __setattr__(self, key, value): super(RuntimeAttrValidation, self).__setattr__(key, value) -class EmptyDictType(Mapping[Any, None]): +class EmptyDictType(MutableMapping[Any, None]): """An empty, immutable dict that returns `None` for any key. Useful as default value for function arguments.""" def __getitem__(self, k: Any) -> None: return None + def __setitem__(self, k: Any, v: None) -> None: + warnings.warn("%s[%r] = %s ignored" % (self.__class__.__name__, k, v)) + return + + def __delitem__(self, v: Any) -> None: + warnings.warn("del %s[%r] ignored" % (self.__class__.__name__, v)) + return + def __len__(self) -> int: return 0 @@ -121,9 +130,9 @@ def __iter__(self) -> Iterator[Any]: EmptyDict = EmptyDictType() ExtraParams = NewType("ExtraParams", Dict[str, List[str]]) -EmptyParams = cast("ExtraParams", EmptyDictType()) +EmptyParams = cast("ExtraParams", EmptyDict) ContextDict = NewType("ContextDict", Dict[Any, Any]) -EmptyContext = cast("ContextDict", EmptyDictType()) +EmptyContext = cast("ContextDict", EmptyDict) def copy_extra_params(old: Optional[ExtraParams]) -> ExtraParams: From f8465437c49805cf8040403dcc0e0681e081575e Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Sun, 5 Apr 2020 13:33:32 +0200 Subject: [PATCH 08/43] bring back __str__ and fix doctests --- doc/event-cmp.rst | 6 ++++-- doc/event.rst | 34 +++++++++++++++++----------------- ics/alarm.py | 5 ++--- ics/component.py | 8 +++++++- ics/converter/base.py | 3 +++ ics/event.py | 2 +- ics/grammar/__init__.py | 15 +++++++++++---- ics/icalendar.py | 20 +++++++++++++------- ics/utils.py | 4 ++++ 9 files changed, 62 insertions(+), 35 deletions(-) diff --git a/doc/event-cmp.rst b/doc/event-cmp.rst index 1301fc67..ee799af8 100644 --- a/doc/event-cmp.rst +++ b/doc/event-cmp.rst @@ -67,7 +67,9 @@ attributes. >>> e = ics.Event() >>> e # doctest: +ELLIPSIS Event(extra=Container('VEVENT', []), extra_params={}, _timespan=EventTimespan(begin_time=None, end_time=None, duration=None, precision='second'), summary=None, uid='...@....org', description=None, location=None, url=None, status=None, created=None, last_modified=None, dtstamp=datetime.datetime(2020, ..., tzinfo=tzutc()), alarms=[], classification=None, transparent=None, organizer=None, geo=None, attendees=[], categories=[]) - >>> e.to_container().serialize() # doctest: +ELLIPSIS + >>> str(e) + '' + >>> e.serialize() # doctest: +ELLIPSIS 'BEGIN:VEVENT\r\nUID:...@....org\r\nDTSTAMP:2020...T...Z\r\nEND:VEVENT' >>> import attr, pprint >>> pprint.pprint(attr.asdict(e)) # doctest: +ELLIPSIS @@ -87,9 +89,9 @@ attributes. 'geo': None, 'last_modified': None, 'location': None, - 'summary': None, 'organizer': None, 'status': None, + 'summary': None, 'transparent': None, 'uid': '...@....org', 'url': None} diff --git a/doc/event.rst b/doc/event.rst index 371fe1e7..1c480d58 100644 --- a/doc/event.rst +++ b/doc/event.rst @@ -18,8 +18,8 @@ Now, we are ready to create our first event :tada: :: >>> e = ics.Event(begin=dt(2020, 2, 20, 20, 20)) - >>> e - + >>> str(e) + '' We specified no end time or duration for the event, so the event defaults to ending at the same instant it begins. The event is also @@ -32,8 +32,8 @@ the event to set the end time explicitly: :: >>> e.end = dt(2020, 2, 22, 20, 20) - >>> e - + >>> str(e) + '' Now, the duration of the event explicitly shows up and the end time is also marked as being set or “fixed” to a certain instant. If we now set @@ -43,8 +43,8 @@ correspondingly: :: >>> e.duration=td(days=2) - >>> e - + >>> str(e) + '' As we now specified the duration explicitly, the duration is now fixed instead of the end time. This actually makes a big difference when you @@ -54,15 +54,15 @@ now change the start time of the event: >>> e1 = ics.Event(begin=dt(2020, 2, 20, 20, 20), end=dt(2020, 2, 22, 20, 20)) >>> e2 = ics.Event(begin=dt(2020, 2, 20, 20, 20), duration=td(days=2)) - >>> e1 - - >>> e2 - + >>> str(e1) + '' + >>> str(e2) + '' >>> e1.begin = e2.begin = dt(2020, 1, 10, 10, 10) - >>> e1 - - >>> e2 - + >>> str(e1) + '' + >>> str(e2) + '' As we just saw, duration and end can also be passed to the constructor, but both are only allowed when a begin time for the event is specified, @@ -70,7 +70,7 @@ and both can’t be set at the same time: :: - >>> ics.Event(end=dt(2020,2,22,20,20)) + >>> ics.Event(end=dt(2020, 2, 22, 20, 20)) Traceback (most recent call last): ... ValueError: event timespan without begin time can't have end time @@ -78,7 +78,7 @@ and both can’t be set at the same time: Traceback (most recent call last): ... ValueError: timespan without begin time can't have duration - >>> ics.Event(begin=dt(2020,2,20, 20,20), end=dt(2020,2,22,20,20), duration=td(2)) + >>> ics.Event(begin=dt(2020, 2, 20, 20, 20), end=dt(2020, 2, 22, 20, 20), duration=td(2)) Traceback (most recent call last): ... ValueError: can't set duration together with end time @@ -88,7 +88,7 @@ won’t be able to set its duration or end: :: - >>> ics.Event().end = dt(2020,2,22,20,20) + >>> ics.Event().end = dt(2020, 2, 22, 20, 20) Traceback (most recent call last): ... ValueError: event timespan without begin time can't have end time diff --git a/ics/alarm.py b/ics/alarm.py index 07592634..745a2373 100644 --- a/ics/alarm.py +++ b/ics/alarm.py @@ -45,9 +45,8 @@ def validate(self, attr=None, value=None): @property @abstractmethod def action(self): - """ VALARM action to be implemented by concrete classes - """ - raise NotImplementedError("Base class cannot be instantiated directly") + """ VALARM action to be implemented by concrete classes """ + pass @attr.s diff --git a/ics/component.py b/ics/component.py index 89655f0e..6c52635b 100644 --- a/ics/component.py +++ b/ics/component.py @@ -32,9 +32,15 @@ def __init_subclass__(cls): def from_container(cls: Type[ComponentType], container: Container) -> ComponentType: return cls.Meta.load_instance(container) # type: ignore - def to_container(self): + def populate(self, container: Container): + self.Meta.populate_instance(self, container) # type: ignore + + def to_container(self) -> Container: return self.Meta.serialize_toplevel(self) # type: ignore + def serialize(self) -> str: + return self.to_container().serialize() + def strip_extras(self, all_extras=False, extra_properties=None, extra_params=None, property_merging=None): if extra_properties is None: extra_properties = all_extras diff --git a/ics/converter/base.py b/ics/converter/base.py index c18f275b..2807f8a9 100644 --- a/ics/converter/base.py +++ b/ics/converter/base.py @@ -12,6 +12,9 @@ from ics.converter.component import InflatedComponentMeta +# TODO make validation / ValueError / warnings configurable +# TODO use repr for warning messages and ensure that they don't get to long + class GenericConverter(abc.ABC): @property @abc.abstractmethod diff --git a/ics/event.py b/ics/event.py index c704ccdf..32ec4908 100644 --- a/ics/event.py +++ b/ics/event.py @@ -146,7 +146,7 @@ def convert_timezone(self, tzinfo): def timespan(self) -> Timespan: return self._timespan - def __repr__(self) -> str: + def __str__(self) -> str: name = [self.__class__.__name__] if self.summary: name.append("'%s'" % self.summary) diff --git a/ics/grammar/__init__.py b/ics/grammar/__init__.py index 34f64472..e46d125f 100644 --- a/ics/grammar/__init__.py +++ b/ics/grammar/__init__.py @@ -7,7 +7,8 @@ import tatsu from tatsu.exceptions import FailedToken -from ics.types import ContainerItem, ExtraParams, RuntimeAttrValidation +from ics.types import ContainerItem, ExtraParams, RuntimeAttrValidation, copy_extra_params +from ics.utils import limit_str_length grammar_path = Path(__file__).parent.joinpath('contentline.ebnf') @@ -35,6 +36,7 @@ class ContentLine(RuntimeAttrValidation): params: ExtraParams = attr.ib(factory=lambda: ExtraParams(dict())) value: str = attr.ib(default="") + # TODO store value type for jCal and line number for error messages # TODO ensure (parameter) value escaping and name normalization def serialize(self): @@ -82,7 +84,10 @@ def interpret_ast(cls, ast): def clone(self): """Makes a copy of itself""" - return attr.evolve(self) + return attr.evolve(self, params=copy_extra_params(self.params)) + + def __str__(self): + return "%s%s='%s'" % (self.name, self.params or "", limit_str_length(self.value)) class Container(List[ContainerItem]): @@ -101,7 +106,7 @@ def __init__(self, name: str, *items: ContainerItem): self.name = name.upper() def __str__(self): - return "Container:%s%s" % (self.name, super(Container, self).__repr__()) + return "%s[%s]" % (self.name, ", ".join(str(cl) for cl in self)) def __repr__(self): return "%s(%r, %s)" % (type(self).__name__, self.name, super(Container, self).__repr__()) @@ -129,10 +134,12 @@ def parse(cls, name, tokenized_lines): items.append(line) return cls(name, *items) - def clone(self, items=None): + def clone(self, items=None, deep=False): """Makes a copy of itself""" if items is None: items = self + if deep: + items = (item.clone() for item in items) return type(self)(self.name, *items) def check_items(self, *items): diff --git a/ics/icalendar.py b/ics/icalendar.py index a3a35f02..10dbb274 100644 --- a/ics/icalendar.py +++ b/ics/icalendar.py @@ -42,7 +42,7 @@ class Calendar(CalendarAttrs): def __init__( self, - imports: Union[str, Container] = None, + imports: Union[str, Container, None] = None, events: Optional[Iterable[Event]] = None, todos: Optional[Iterable[Todo]] = None, creator: str = None, @@ -67,14 +67,13 @@ def __init__( if imports is not None: if isinstance(imports, Container): - self.Meta.populate_instance(self, imports) # type:ignore + self.populate(imports) else: containers = calendar_string_to_containers(imports) if len(containers) != 1: - raise NotImplementedError( - 'Multiple calendars in one file are not supported by this method. Use ics.Calendar.parse_multiple()') - - self.Meta.populate_instance(self, containers[0]) # type:ignore + raise ValueError("Multiple calendars in one file are not supported by this method." + "Use ics.Calendar.parse_multiple()") + self.populate(containers[0]) @property def creator(self) -> str: @@ -93,6 +92,13 @@ def parse_multiple(cls, string): containers = calendar_string_to_containers(string) return [cls(imports=c) for c in containers] + def __str__(self) -> str: + return "".format( + len(self.events), + "s" if len(self.events) > 1 else "", + len(self.todos), + "s" if len(self.todos) > 1 else "") + def __iter__(self) -> Iterable[str]: """Returns: iterable: an iterable version of __str__, line per line @@ -104,4 +110,4 @@ def __iter__(self) -> Iterable[str]: >>> c = Calendar(); c.events.append(Event(summary="My cool event")) >>> open('my.ics', 'w').writelines(c) """ - return iter(str(self).splitlines(keepends=True)) + return iter(self.serialize().splitlines(keepends=True)) diff --git a/ics/utils.py b/ics/utils.py index 3434a50c..19a6c093 100644 --- a/ics/utils.py +++ b/ics/utils.py @@ -165,6 +165,10 @@ def ceil_timedelta_to_days(value: timedelta) -> timedelta: # String Utils +def limit_str_length(val): + return str(val) # TODO + + def uid_gen() -> str: uid = str(uuid4()) return "{}@{}.org".format(uid, uid[:4]) From 3cafe53f66c85ebd5f2d8769be5edb8d2c612f92 Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Mon, 6 Apr 2020 20:34:46 +0200 Subject: [PATCH 09/43] make zip safe using importlib_resources --- ics/grammar/__init__.py | 10 ++++------ requirements.txt | 4 ++-- setup.py | 2 +- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/ics/grammar/__init__.py b/ics/grammar/__init__.py index e46d125f..b3e9b03b 100644 --- a/ics/grammar/__init__.py +++ b/ics/grammar/__init__.py @@ -4,16 +4,14 @@ from typing import List import attr -import tatsu -from tatsu.exceptions import FailedToken +import importlib_resources # type: ignore +import tatsu # type: ignore +from tatsu.exceptions import FailedToken # type: ignore from ics.types import ContainerItem, ExtraParams, RuntimeAttrValidation, copy_extra_params from ics.utils import limit_str_length -grammar_path = Path(__file__).parent.joinpath('contentline.ebnf') - -with open(grammar_path) as fd: - GRAMMAR = tatsu.compile(fd.read()) +GRAMMAR = tatsu.compile(importlib_resources.read_text(__name__, "contentline.ebnf")) class ParseError(Exception): diff --git a/requirements.txt b/requirements.txt index 558d0904..10b635b6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ python-dateutil -six>1.5 tatsu>4.2 -attrs>=19.2 \ No newline at end of file +attrs>=19.2 +importlib_resources diff --git a/setup.py b/setup.py index 75eea85c..d6536127 100755 --- a/setup.py +++ b/setup.py @@ -69,5 +69,5 @@ def readme(): cmdclass={'test': PyTest}, tests_require=tests_require, test_suite="py.test", - zip_safe=False, + zip_safe=True, ) From e44e4b30b2d4438b978131a5bf1598727da8a391 Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Fri, 10 Apr 2020 11:39:47 +0200 Subject: [PATCH 10/43] improved handling of escaped strings, testing --- doc/event-cmp.rst | 4 +- ics/__init__.py | 3 +- ics/converter/value.py | 18 +-- ics/grammar/__init__.py | 187 +++++++++++++++++--------- ics/types.py | 29 +++- ics/utils.py | 52 ++------ ics/valuetype/base.py | 8 +- ics/valuetype/generic.py | 25 +--- ics/valuetype/text.py | 68 ++++++++++ tests/contentline.py | 83 ------------ tests/grammar/__init__.py | 256 ++++++++++++++++++++++++++++++++++++ tests/valuetype/__init__.py | 0 tests/valuetype/text.py | 73 ++++++++++ 13 files changed, 586 insertions(+), 220 deletions(-) create mode 100644 ics/valuetype/text.py delete mode 100644 tests/contentline.py create mode 100644 tests/grammar/__init__.py create mode 100644 tests/valuetype/__init__.py create mode 100644 tests/valuetype/text.py diff --git a/doc/event-cmp.rst b/doc/event-cmp.rst index ee799af8..7c2fca70 100644 --- a/doc/event-cmp.rst +++ b/doc/event-cmp.rst @@ -70,7 +70,7 @@ attributes. >>> str(e) '' >>> e.serialize() # doctest: +ELLIPSIS - 'BEGIN:VEVENT\r\nUID:...@....org\r\nDTSTAMP:2020...T...Z\r\nEND:VEVENT' + 'BEGIN:VEVENT\r\nUID:...@...org\r\nDTSTAMP:2020...T...Z\r\nEND:VEVENT' >>> import attr, pprint >>> pprint.pprint(attr.asdict(e)) # doctest: +ELLIPSIS {'_timespan': {'begin_time': None, @@ -84,7 +84,7 @@ attributes. 'created': None, 'description': None, 'dtstamp': datetime.datetime(2020, ..., tzinfo=tzutc()), - 'extra': [], + 'extra': {'data': [], 'name': 'VEVENT'}, 'extra_params': {}, 'geo': None, 'last_modified': None, diff --git a/ics/__init__.py b/ics/__init__.py index 937d5b6a..50a62f94 100644 --- a/ics/__init__.py +++ b/ics/__init__.py @@ -6,7 +6,8 @@ def load_converters(): from ics.converter.value import AttributeValueConverter from ics.valuetype.base import ValueConverter from ics.valuetype.datetime import DateConverter, DatetimeConverter, DurationConverter, PeriodConverter, TimeConverter, UTCOffsetConverter - from ics.valuetype.generic import BinaryConverter, BooleanConverter, CalendarUserAddressConverter, FloatConverter, IntegerConverter, RecurConverter, TextConverter, URIConverter + from ics.valuetype.generic import BinaryConverter, BooleanConverter, CalendarUserAddressConverter, FloatConverter, IntegerConverter, RecurConverter, URIConverter + from ics.valuetype.text import TextConverter from ics.valuetype.special import GeoConverter diff --git a/ics/converter/value.py b/ics/converter/value.py index 8731555c..0deba572 100644 --- a/ics/converter/value.py +++ b/ics/converter/value.py @@ -35,7 +35,7 @@ def ics_name(self) -> str: name = self.attribute.name.upper().replace("_", "-").strip("-") return name - def __parse_value(self, line: "ContentLine", value: str, context: ContextDict) -> Tuple[ExtraParams, ValueConverter]: + def __prepare_params(self, line: "ContentLine") -> Tuple[ExtraParams, ValueConverter]: params = copy_extra_params(line.params) value_type = params.pop("VALUE", None) if value_type: @@ -48,8 +48,7 @@ def __parse_value(self, line: "ContentLine", value: str, context: ContextDict) - raise ValueError("can't convert %s with %s" % (line, self)) else: converter = self.value_converters[0] - parsed = converter.parse(value, params, context) # might modify params and context - return params, parsed + return params, converter # TODO make storing/writing extra values/params configurably optional, but warn when information is lost @@ -57,10 +56,11 @@ def populate(self, component: "Component", item: ContainerItem, context: Context assert isinstance(item, ContentLine) self._check_component(component, context) if self.is_multi_value: - params = None - for value in item.value_list: + params, converter = self.__prepare_params(item) + for value in converter.split_value_list(item.value): context[(self, "current_value_count")] += 1 - params, parsed = self.__parse_value(item, value, context) + params = copy_extra_params(params) + parsed = converter.parse(value, params, context) # might modify params and context params["__merge_next"] = True # type: ignore self.set_or_append_extra_params(component, params) self.set_or_append_value(component, parsed) @@ -70,7 +70,8 @@ def populate(self, component: "Component", item: ContainerItem, context: Context if context[(self, "current_value_count")] > 0: raise ValueError("attribute %s can only be set once, second occurrence is %s" % (self.ics_name, item)) context[(self, "current_value_count")] += 1 - params, parsed = self.__parse_value(item, item.value, context) + params, converter = self.__prepare_params(item) + parsed = converter.parse(item.value, params, context) # might modify params and context self.set_or_append_extra_params(component, params) self.set_or_append_value(component, parsed) return True @@ -129,8 +130,7 @@ def __serialize_multi(self, component: "Component", output: "Container", context current_values.append(serialized) if not merge_next: - cl = ContentLine(name=self.ics_name, params=params) - cl.value_list = current_values + cl = ContentLine(name=self.ics_name, params=params, value=converter.join_value_list(current_values)) output.append(cl) current_params = None current_values = [] diff --git a/ics/grammar/__init__.py b/ics/grammar/__init__.py index b3e9b03b..76440870 100644 --- a/ics/grammar/__init__.py +++ b/ics/grammar/__init__.py @@ -1,7 +1,7 @@ -import collections +import functools import re -from pathlib import Path -from typing import List +import warnings +from typing import Generator, List, MutableSequence import attr import importlib_resources # type: ignore @@ -9,7 +9,7 @@ from tatsu.exceptions import FailedToken # type: ignore from ics.types import ContainerItem, ExtraParams, RuntimeAttrValidation, copy_extra_params -from ics.utils import limit_str_length +from ics.utils import limit_str_length, next_after_str_escape, validate_truthy GRAMMAR = tatsu.compile(importlib_resources.read_text(__name__, "contentline.ebnf")) @@ -35,28 +35,39 @@ class ContentLine(RuntimeAttrValidation): value: str = attr.ib(default="") # TODO store value type for jCal and line number for error messages - # TODO ensure (parameter) value escaping and name normalization def serialize(self): - params_str = '' + return "".join(self.serialize_iter()) + + def serialize_iter(self, newline=False): + yield self.name for pname in self.params: - params_str += ';{}={}'.format(pname, ','.join(self.params[pname])) - return "{}{}:{}".format(self.name, params_str, self.value) + yield ";" + yield pname + yield "=" + for nr, pval in enumerate(self.params[pname]): + if nr > 0: + yield "," + if re.search("[:;,]", pval): + # Property parameter values that contain the COLON, SEMICOLON, or COMMA character separators + # MUST be specified as quoted-string text values. + # TODO The DQUOTE character is used as a delimiter for parameter values that contain + # restricted characters or URI text. + # TODO Property parameter values that are not in quoted-strings are case-insensitive. + yield '"%s"' % escape_param(pval) + else: + yield escape_param(pval) + yield ":" + yield self.value + if newline: + yield "\r\n" def __getitem__(self, item): return self.params[item] - def __setitem__(self, item, *values): + def __setitem__(self, item, values): self.params[item] = list(values) - @property - def value_list(self) -> List[str]: - return re.split("(? str: + return string.translate( + {ord("\""): "^'", + ord("^"): "^^", + ord("\n"): "^n", + ord("\r"): ""}) + + +def unescape_param(string: str) -> str: + return "".join(unescape_param_iter(string)) + + +def unescape_param_iter(string: str) -> Generator[str, None, None]: + it = iter(string) + for c1 in it: + if c1 == "^": + c2 = next_after_str_escape(it, full_str=string) + if c2 == "n": + yield "\n" + elif c2 == "^": + yield "^" + elif c2 == "'": + yield "\"" + else: + yield c1 + yield c2 + else: + yield c1 def unfold_lines(physical_lines): - if not isinstance(physical_lines, collections.abc.Iterable): - raise ParseError('Parameter `physical_lines` must be an iterable') current_line = '' for line in physical_lines: + line = line.rstrip('\r') if len(line.strip()) == 0: continue elif not current_line: - current_line = line.strip('\r') + current_line = line elif line[0] in (' ', '\t'): - current_line += line[1:].strip('\r') + current_line += line[1:] else: yield current_line - current_line = line.strip('\r') + current_line = line if current_line: yield current_line @@ -216,9 +285,3 @@ def lines_to_container(lines): def string_to_container(txt): return lines_to_container(txt.splitlines()) - - -def calendar_string_to_containers(string): - if not isinstance(string, str): - raise TypeError("Expecting a string") - return string_to_container(string) diff --git a/ics/types.py b/ics/types.py index 58ae29ba..2ae68d15 100644 --- a/ics/types.py +++ b/ics/types.py @@ -1,6 +1,8 @@ +import functools import warnings from datetime import date, datetime, timedelta from typing import Any, Dict, Iterator, List, MutableMapping, NewType, Optional, TYPE_CHECKING, Tuple, Union, cast, overload +from urllib.parse import ParseResult import attr @@ -15,7 +17,7 @@ from ics.grammar import ContentLine, Container __all__ = [ - "ContainerItem", "ContainerList", + "ContainerItem", "ContainerList", "URL", "DatetimeLike", "OptionalDatetimeLike", "TimedeltaLike", "OptionalTimedeltaLike", @@ -37,6 +39,7 @@ ContainerItem = Union["ContentLine", "Container"] ContainerList = List[ContainerItem] +URL = ParseResult DatetimeLike = Union[Tuple, Dict, datetime, date] OptionalDatetimeLike = Union[Tuple, Dict, datetime, date, None] @@ -147,3 +150,27 @@ def copy_extra_params(old: Optional[ExtraParams]) -> ExtraParams: else: raise ValueError("can't convert extra param %s with value of type %s: %s" % (key, type(value), value)) return new + + +def attrs_custom_init(cls): + assert attr.has(cls) + attr_init = cls.__init__ + custom_init = cls.__attr_custom_init__ + + @functools.wraps(attr_init) + def new_init(self, *args, **kwargs): + custom_init(self, attr_init, *args, **kwargs) + + cls.__init__ = new_init + cls.__attr_custom_init__ = None + del cls.__attr_custom_init__ + return cls + +# @attrs_custom_init +# @attr.s +# class Test(object): +# val1 = attr.ib() +# val2 = attr.ib() +# +# def __attr_custom_init__(self, attr_init, val1, val1_suffix, *args, **kwargs): +# attr_init(self, val1 + val1_suffix, *args, **kwargs) diff --git a/ics/utils.py b/ics/utils.py index 19a6c093..38153e20 100644 --- a/ics/utils.py +++ b/ics/utils.py @@ -1,5 +1,5 @@ from datetime import date, datetime, time, timedelta, timezone -from typing import Generator, overload +from typing import overload from uuid import uuid4 from dateutil.tz import UTC as dateutil_tzutc @@ -68,12 +68,9 @@ def is_utc(instant: datetime) -> bool: return False if tz in [dateutil_tzutc, datetime_tzutc]: return True - offset = tz.utcoffset(instant) - if offset == TIMEDELTA_ZERO: + tzname = tz.tzname(instant) + if tzname and tzname.upper() == "UTC": return True - # tzname = tz.tzname(instant) - # if tzname and tzname.upper() == "UTC": - # return True return False @@ -166,7 +163,14 @@ def ceil_timedelta_to_days(value: timedelta) -> timedelta: def limit_str_length(val): - return str(val) # TODO + return str(val) # TODO limit_str_length + + +def next_after_str_escape(it, full_str): + try: + return next(it) + except StopIteration as e: + raise ValueError("value '%s' may not end with an escape sequence" % full_str) from e def uid_gen() -> str: @@ -174,40 +178,6 @@ def uid_gen() -> str: return "{}@{}.org".format(uid, uid[:4]) -def escape_string(string: str) -> str: - return string.translate( - {ord("\\"): "\\\\", - ord(";"): "\\;", - ord(","): "\\,", - ord("\n"): "\\n", - ord("\r"): "\\r"}) - - -def unescape_string(string: str) -> str: - return "".join(unescape_string_iter(string)) - - -def unescape_string_iter(string: str) -> Generator[str, None, None]: - it = iter(string) - for c1 in it: - if c1 == "\\": - c2 = next(it) - if c2 == ";": - yield ";" - elif c2 == ",": - yield "," - elif c2 == "n" or c2 == "N": - yield "\n" - elif c2 == "r" or c2 == "R": - yield "\r" - elif c2 == "\\": - yield "\\" - else: - raise ValueError("can't handle escaped character '%s'" % c2) - else: - yield c1 - - ############################################################################### def validate_not_none(inst, attr, value): diff --git a/ics/valuetype/base.py b/ics/valuetype/base.py index ae344195..280b55c2 100644 --- a/ics/valuetype/base.py +++ b/ics/valuetype/base.py @@ -1,6 +1,6 @@ import abc import inspect -from typing import Dict, Generic, Type, TypeVar +from typing import Dict, Generic, Iterable, Type, TypeVar from ics.types import ContextDict, EmptyContext, EmptyParams, ExtraParams @@ -29,6 +29,12 @@ def ics_type(self) -> str: def python_type(self) -> Type[T]: pass + def split_value_list(self, values: str) -> Iterable[str]: + yield from values.split(",") + + def join_value_list(self, values: Iterable[str]) -> str: + return ",".join(values) + @abc.abstractmethod def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> T: pass diff --git a/ics/valuetype/generic.py b/ics/valuetype/generic.py index b8d20da2..4c2cdc22 100644 --- a/ics/valuetype/generic.py +++ b/ics/valuetype/generic.py @@ -1,30 +1,13 @@ import base64 from typing import Type -from urllib.parse import ParseResult as URL, urlparse +from urllib.parse import urlparse from dateutil.rrule import rrule -from ics.types import ContextDict, EmptyContext, EmptyParams, ExtraParams +from ics.types import ContextDict, EmptyContext, EmptyParams, ExtraParams, URL from ics.valuetype.base import ValueConverter -class TextConverter(ValueConverter[str]): - - @property - def ics_type(self) -> str: - return "TEXT" - - @property - def python_type(self) -> Type[str]: - return str - - def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: - return value - - def serialize(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: - return value - - class BinaryConverter(ValueConverter[bytes]): @property @@ -126,13 +109,15 @@ def python_type(self) -> Type[rrule]: def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> rrule: # this won't be called unless a class specifies an attribute with type: rrule - raise NotImplementedError("parsing 'RECUR' is not yet supported") + raise NotImplementedError("parsing 'RECUR' is not yet supported") # TODO is this a valuetype or a composed object def serialize(self, value: rrule, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: raise NotImplementedError("serializing 'RECUR' is not yet supported") class URIConverter(ValueConverter[URL]): + # TODO URI PARAMs need percent escaping, preventing all illegal characters except for ", in which they also need to wrapped + # TODO URI values also need percent escaping (escaping COMMA characters in URI Lists), but no quoting @property def ics_type(self) -> str: diff --git a/ics/valuetype/text.py b/ics/valuetype/text.py new file mode 100644 index 00000000..0a172972 --- /dev/null +++ b/ics/valuetype/text.py @@ -0,0 +1,68 @@ +from typing import Iterable, Iterator, Type + +from ics.types import ContextDict, EmptyContext, EmptyParams, ExtraParams +from ics.utils import next_after_str_escape +from ics.valuetype.base import ValueConverter + + +class TextConverter(ValueConverter[str]): + + @property + def ics_type(self) -> str: + return "TEXT" + + @property + def python_type(self) -> Type[str]: + return str + + def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: + return self.unescape_text(value) + + def serialize(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: + return self.escape_text(value) + + def split_value_list(self, values: str) -> Iterable[str]: + it = iter(values.split(",")) + for val in it: + while val.endswith("\\") and not val.endswith("\\\\"): + val += "," + next_after_str_escape(it, full_str=values) + yield val + + # def join_value_list(self, values: Iterable[str]) -> str: + # return ",".join(values) # TODO warn about missing escapes + + @classmethod + def escape_text(cls, string: str) -> str: + return string.translate( + {ord("\\"): "\\\\", + ord(";"): "\\;", + ord(","): "\\,", + ord("\n"): "\\n", + ord("\r"): "\\r"}) + + @classmethod + def unescape_text(cls, string: str) -> str: + return "".join(cls.unescape_text_iter(string)) + + @classmethod + def unescape_text_iter(cls, string: str) -> Iterator[str]: + it = iter(string) + for c1 in it: + if c1 == "\\": + c2 = next_after_str_escape(it, full_str=string) + if c2 == ";": + yield ";" + elif c2 == ",": + yield "," + elif c2 == "n" or c2 == "N": + yield "\n" + elif c2 == "r" or c2 == "R": + yield "\r" + elif c2 == "\\": + yield "\\" + else: + raise ValueError("can't handle escaped character '%s'" % c2) + elif c1 in ";,\n\r": + raise ValueError("unescaped character '%s' in TEXT value" % c1) + else: + yield c1 diff --git a/tests/contentline.py b/tests/contentline.py deleted file mode 100644 index 82d0a507..00000000 --- a/tests/contentline.py +++ /dev/null @@ -1,83 +0,0 @@ -import unittest - -from ics.grammar.parse import ContentLine, ParseError -from ics.utils import parse_datetime - - -class TestContentLine(unittest.TestCase): - - dataset = { - 'HAHA:': ContentLine('haha'), - 'HAHA:hoho': ContentLine('haha', {}, 'hoho'), - 'HAHA:hoho:hihi': ContentLine('haha', {}, 'hoho:hihi'), - 'HAHA;hoho=1:hoho': ContentLine('haha', {'hoho': ['1']}, 'hoho'), - 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU': - ContentLine( - 'RRULE', - {}, - 'FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU' - ), - 'SUMMARY:dfqsdfjqkshflqsjdfhqs fqsfhlqs dfkqsldfkqsdfqsfqsfqsfs': - ContentLine( - 'SUMMARY', - {}, - 'dfqsdfjqkshflqsjdfhqs fqsfhlqs dfkqsldfkqsdfqsfqsfqsfs' - ), - 'DTSTART;TZID=Europe/Brussels:20131029T103000': - ContentLine( - 'DTSTART', - {'TZID': ['Europe/Brussels']}, - '20131029T103000' - ), - } - - dataset2 = { - 'haha;p2=v2;p1=v1:': - ContentLine( - 'haha', - {'p1': ['v1'], 'p2': ['v2']}, - '' - ), - 'haha;hihi=p3,p4,p5;hoho=p1,p2:blabla:blublu': - ContentLine( - 'haha', - {'hoho': ['p1', 'p2'], 'hihi': ['p3', 'p4', 'p5']}, - 'blabla:blublu' - ), - r'ATTENDEE;X-A="I&rsquo\;ll be in NYC":mailto:a@a.com': - ContentLine( - 'ATTENDEE', - {'X-A': [r"I&rsquo\;ll be in NYC"]}, - 'mailto:a@a.com', - ), - 'DTEND;TZID="UTC":20190107T000000': - ContentLine( - "DTEND", - {'TZID': ['UTC']}, - "20190107T000000" - ) - } - - def test_errors(self): - self.assertRaises(ParseError, ContentLine.parse, 'haha;p1=v1') - self.assertRaises(ParseError, ContentLine.parse, 'haha;p1:') - - def test_str(self): - for test in self.dataset: - expected = test - got = str(self.dataset[test]) - self.assertEqual(expected, got) - - def test_parse(self): - self.dataset2.update(self.dataset) - for test in self.dataset2: - expected = self.dataset2[test] - got = ContentLine.parse(test) - self.assertEqual(expected, got) - - - # https://github.com/C4ptainCrunch/ics.py/issues/68 - def test_timezone_not_dropped(self): - line = ContentLine.parse("DTSTART;TZID=Europe/Berlin:20151104T190000") - parsed = parse_datetime(line) - self.assertIn("Europe/Berlin", str(parsed.tzinfo)) diff --git a/tests/grammar/__init__.py b/tests/grammar/__init__.py new file mode 100644 index 00000000..d602661e --- /dev/null +++ b/tests/grammar/__init__.py @@ -0,0 +1,256 @@ +import pytest +from hypothesis import assume, given +from hypothesis.strategies import characters, text + +from ics.grammar import Container, ContentLine, ParseError, string_to_container, unfold_lines + +NAME = text(alphabet=(characters(whitelist_categories=["Lu"], whitelist_characters=["-"], max_codepoint=128)), min_size=1) +VALUE = text(characters(blacklist_categories=["Cs"], blacklist_characters=["\n", "\r"])) + + +@pytest.mark.parametrize("inp, out", [ + ('HAHA:', ContentLine(name='HAHA', params={}, value='')), + ('HAHA:hoho', ContentLine(name='HAHA', params={}, value='hoho')), + ('HAHA:hoho:hihi', ContentLine(name='HAHA', params={}, value='hoho:hihi')), + ( + 'HAHA;hoho=1:hoho', + ContentLine(name='HAHA', params={'hoho': ['1']}, value='hoho') + ), ( + 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU', + ContentLine(name='RRULE', params={}, value='FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU') + ), ( + 'SUMMARY:dfqsdfjqkshflqsjdfhqs fqsfhlqs dfkqsldfkqsdfqsfqsfqsfs', + ContentLine(name='SUMMARY', params={}, value='dfqsdfjqkshflqsjdfhqs fqsfhlqs dfkqsldfkqsdfqsfqsfqsfs') + ), ( + 'DTSTART;TZID=Europe/Brussels:20131029T103000', + ContentLine(name='DTSTART', params={'TZID': ['Europe/Brussels']}, value='20131029T103000') + ), ( + 'haha;p2=v2;p1=v1:', + ContentLine(name='HAHA', params={'p1': ['v1'], 'p2': ['v2']}, value='') + ), ( + 'haha;hihi=p3,p4,p5;hoho=p1,p2:blabla:blublu', + ContentLine(name='HAHA', params={'hoho': ['p1', 'p2'], 'hihi': ['p3', 'p4', 'p5']}, value='blabla:blublu') + ), ( + 'ATTENDEE;X-A="I&rsquo\\;ll be in NYC":mailto:a@a.com', + ContentLine(name='ATTENDEE', params={'X-A': ['I&rsquo\\;ll be in NYC']}, value='mailto:a@a.com') + ), ( + 'DTEND;TZID="UTC":20190107T000000', + ContentLine(name='DTEND', params={'TZID': ['UTC']}, value='20190107T000000') + ), ( + "ATTENDEE;MEMBER=\"mailto:ietf-calsch@example.org\":mailto:jsmith@example.com", + ContentLine("ATTENDEE", {"MEMBER": ["mailto:ietf-calsch@example.org"]}, "mailto:jsmith@example.com") + ), ( + "ATTENDEE;MEMBER=\"mailto:projectA@example.com\",\"mailto:projectB@example.com\":mailto:janedoe@example.com", + ContentLine("ATTENDEE", {"MEMBER": ["mailto:projectA@example.com", "mailto:projectB@example.com"]}, "mailto:janedoe@example.com") + ), ( + "RESOURCES:EASEL,PROJECTOR,VCR", + ContentLine("RESOURCES", value="EASEL,PROJECTOR,VCR") + ), ( + "ATTENDEE;CN=George Herman ^'Babe^' Ruth:mailto:babe@example.com", + ContentLine("ATTENDEE", {"CN": ["George Herman \"Babe\" Ruth"]}, "mailto:babe@example.com") + ), ( + "GEO;X-ADDRESS=Pittsburgh Pirates^n115 Federal St^nPittsburgh, PA 15212:40.446816,-80.00566", + ContentLine("GEO", {"X-ADDRESS": ["Pittsburgh Pirates\n115 Federal St\nPittsburgh", " PA 15212"]}, "40.446816,-80.00566") + ), ( + "GEO;X-ADDRESS=\"Pittsburgh Pirates^n115 Federal St^nPittsburgh, PA 15212\":40.446816,-80.00566", + ContentLine("GEO", {"X-ADDRESS": ["Pittsburgh Pirates\n115 Federal St\nPittsburgh, PA 15212"]}, "40.446816,-80.00566") + ), ( + "SUMMARY:Project XYZ Final Review\\nConference Room - 3B\\nCome Prepared.", + ContentLine("SUMMARY", value="Project XYZ Final Review\\nConference Room - 3B\\nCome Prepared.") + ), ( + "DESCRIPTION;ALTREP=\"cid:part1.0001@example.org\":The Fall'98 Wild Wizards Conference - - Las Vegas\\, NV\\, USA", + ContentLine("DESCRIPTION", {"ALTREP": ["cid:part1.0001@example.org"]}, value="The Fall'98 Wild Wizards Conference - - Las Vegas\\, NV\\, USA") + ), + +]) +def test_example_recode(inp, out): + par = ContentLine.parse(inp) + assert par == out + ser = out.serialize() + if inp[0].isupper(): + assert inp == ser + else: + assert inp.upper() == ser.upper() + par_ser = par.serialize() + if inp[0].isupper(): + assert inp == par_ser + else: + assert inp.upper() == par_ser.upper() + assert string_to_container(inp) == [out] + + +def test_trailing_escape_param(): + with pytest.raises(ValueError) as excinfo: + ContentLine.parse("TEST;PARAM=this ^^ is a ^'param^',with a ^trailing escape^:value") + assert "not end with an escape sequence" in str(excinfo.value) + assert ContentLine.parse("TEST;PARAM=this ^^ is a ^'param^',with a ^trailing escape:value").params["PARAM"] == \ + ["this ^ is a \"param\"", "with a ^trailing escape"] + + +@given(name=NAME, value=VALUE) +def test_any_name_value_recode(name, value): + raw = "%s:%s" % (name, value) + assert ContentLine.parse(raw).serialize() == raw + cl = ContentLine(name, value=value) + assert ContentLine.parse(cl.serialize()) == cl + assert string_to_container(raw) == [cl] + + +@given(param=NAME, value=VALUE) +def test_any_param_value_recode(param, value): + raw = "TEST;%s=%s:VALUE" % (param, value) + assert ContentLine.parse(raw).serialize() == raw + cl = ContentLine("TEST", {param: value}, "VALUE") + assert ContentLine.parse(cl.serialize()) == cl + assert string_to_container(raw) == [cl] + + +@given(name=NAME, value=VALUE, + param1=NAME, p1value=VALUE, + param2=NAME, p2value=VALUE) +def test_any_name_params_value_recode(name, value, param1, p1value, param2, p2value): + assume(param1 != param2) + raw = "%s;%s=%s;%s=%s:%s" % (name, param1, p1value, param2, p2value, value) + assert ContentLine.parse(raw).serialize() == raw + cl = ContentLine(name, {param1: p1value, param2: p2value}, value) + assert ContentLine.parse(cl.serialize()) == cl + assert string_to_container(raw) == [cl] + + +def test_contentline_parse_error(): + pytest.raises(ParseError, ContentLine.parse, 'haha;p1=v1') + pytest.raises(ParseError, ContentLine.parse, 'haha;p1:') + + +def test_container(): + inp = """BEGIN:TEST +VAL1:The-Val +VAL2;PARAM1=P1;PARAM2=P2A,P2B;PARAM3="P3:A","P3:B,C":The-Val2 +END:TEST""" + out = Container('TEST', [ + ContentLine(name='VAL1', params={}, value='The-Val'), + ContentLine(name='VAL2', params={'PARAM1': ['P1'], 'PARAM2': ['P2A', 'P2B'], 'PARAM3': ['P3:A', 'P3:B,C']}, value='The-Val2')]) + + assert string_to_container(inp) == [out] + assert out.serialize() == inp.replace("\n", "\r\n") + assert str(out) == "TEST[VAL1='The-Val', VAL2{'PARAM1': ['P1'], 'PARAM2': ['P2A', 'P2B'], 'PARAM3': ['P3:A', 'P3:B,C']}='The-Val2']" + assert repr(out) == "Container('TEST', [ContentLine(name='VAL1', params={}, value='The-Val'), ContentLine(name='VAL2', params={'PARAM1': ['P1'], 'PARAM2': ['P2A', 'P2B'], 'PARAM3': ['P3:A', 'P3:B,C']}, value='The-Val2')])" + + out_shallow = out.clone(deep=False) + out_deep = out.clone(deep=True) + assert out == out_shallow == out_deep + assert all(a == b for a, b in zip(out, out_shallow)) + assert all(a == b for a, b in zip(out, out_deep)) + assert all(a is b for a, b in zip(out, out_shallow)) + assert all(a is not b for a, b in zip(out, out_deep)) + out_deep.append(ContentLine("LAST")) + assert out != out_deep + out[0].params["NEW"] = "SOMETHING" + assert out == out_shallow + out_shallow.name = "DIFFERENT" + assert out != out_shallow + + with pytest.raises(TypeError): + out_shallow[0] = ['CONTENT:Line'] + with pytest.raises(TypeError): + out_shallow[:] = ['CONTENT:Line'] + pytest.raises(TypeError, out_shallow.append, 'CONTENT:Line') + pytest.raises(TypeError, out_shallow.append, ['CONTENT:Line']) + pytest.raises(TypeError, out_shallow.extend, ['CONTENT:Line']) + out_shallow[:] = [out[0]] + assert out_shallow == Container("DIFFERENT", [out[0]]) + out_shallow[:] = [] + assert out_shallow == Container("DIFFERENT") + out_shallow.append(ContentLine("CL1")) + out_shallow.extend([ContentLine("CL3")]) + out_shallow.insert(1, ContentLine("CL2")) + out_shallow += [ContentLine("CL4")] + assert out_shallow == Container("DIFFERENT", [ContentLine("CL1"), ContentLine("CL2"), ContentLine("CL3"), ContentLine("CL4")]) + + with pytest.warns(UserWarning, match="not all-uppercase"): + assert string_to_container("BEGIN:test\nEND:TeSt") == [Container("TEST", [])] + + +def test_container_nested(): + inp = """BEGIN:TEST1 +VAL1:The-Val +BEGIN:TEST2 +VAL2:The-Val +BEGIN:TEST3 +VAL3:The-Val +END:TEST3 +END:TEST2 +VAL4:The-Val +BEGIN:TEST2 +VAL5:The-Val +END:TEST2 +BEGIN:TEST2 +VAL5:The-Val +END:TEST2 +VAL6:The-Val +END:TEST1""" + out = Container('TEST1', [ + ContentLine(name='VAL1', params={}, value='The-Val'), + Container('TEST2', [ + ContentLine(name='VAL2', params={}, value='The-Val'), + Container('TEST3', [ + ContentLine(name='VAL3', params={}, value='The-Val') + ]) + ]), + ContentLine(name='VAL4', params={}, value='The-Val'), + Container('TEST2', [ + ContentLine(name='VAL5', params={}, value='The-Val')]), + Container('TEST2', [ + ContentLine(name='VAL5', params={}, value='The-Val')]), + ContentLine(name='VAL6', params={}, value='The-Val')]) + + assert string_to_container(inp) == [out] + assert out.serialize() == inp.replace("\n", "\r\n") + + +def test_container_parse_error(): + pytest.raises(ParseError, string_to_container, "BEGIN:TEST") + assert string_to_container("END:TEST") == [ContentLine(name="END", value="TEST")] + pytest.raises(ParseError, string_to_container, "BEGIN:TEST1\nEND:TEST2") + pytest.raises(ParseError, string_to_container, "BEGIN:TEST1\nEND:TEST2\nEND:TEST1") + assert string_to_container("BEGIN:TEST1\nEND:TEST1\nEND:TEST1") == [Container("TEST1"), ContentLine(name="END", value="TEST1")] + pytest.raises(ParseError, string_to_container, "BEGIN:TEST1\nBEGIN:TEST1\nEND:TEST1") + + +def test_unfold(): + val1 = "DESCRIPTION:This is a long description that exists on a long line." + val2 = "DESCRIPTION:This is a lo\n ng description\n that exists on a long line." + assert "".join(unfold_lines(val2.splitlines())) == val1 + assert string_to_container(val1) == string_to_container(val2) == [ContentLine.parse(val1)] + pytest.raises(ValueError, ContentLine.parse, val2) + + +def test_value_characters(): + chars = "abcABC0123456789" "-=_+!$%&*()[]{}<>'@#~/?|`¬€¨ÄÄää´ÁÁááßæÆ \t\\n😜🇪🇺👩🏾‍💻👨🏻‍👩🏻‍👧🏻‍👦🏻xyzXYZ" + special_chars = ";:,\"^" + inp = "TEST;P1={chars};P2={chars},{chars},\"{chars}\",{chars}:{chars}:{chars}{special}".format( + chars=chars, special=special_chars) + out = ContentLine("TEST", {"P1": [chars], "P2": [chars, chars, chars, chars]}, + chars + ":" + chars + special_chars) + par = ContentLine.parse(inp) + assert par == out + ser = out.serialize() + assert inp == ser + par_ser = par.serialize() + assert inp == par_ser + assert string_to_container(inp) == [out] + + +def test_contentline_funcs(): + cl = ContentLine("TEST", {"PARAM": ["VAL"]}, "VALUE") + assert cl["PARAM"] == ["VAL"] + cl["PARAM2"] = ["VALA", "VALB"] + assert cl.params == {"PARAM": ["VAL"], "PARAM2": ["VALA", "VALB"]} + cl_clone = cl.clone() + assert cl == cl_clone and cl is not cl_clone + assert cl.params == cl_clone.params and cl.params is not cl_clone.params + assert cl.params["PARAM2"] == cl_clone.params["PARAM2"] and cl.params["PARAM2"] is not cl_clone.params["PARAM2"] + cl_clone["PARAM2"].append("VALC") + assert cl != cl_clone + assert str(cl) == "TEST{'PARAM': ['VAL'], 'PARAM2': ['VALA', 'VALB']}='VALUE'" + assert str(cl_clone) == "TEST{'PARAM': ['VAL'], 'PARAM2': ['VALA', 'VALB', 'VALC']}='VALUE'" diff --git a/tests/valuetype/__init__.py b/tests/valuetype/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/valuetype/text.py b/tests/valuetype/text.py new file mode 100644 index 00000000..97fe69fe --- /dev/null +++ b/tests/valuetype/text.py @@ -0,0 +1,73 @@ +import attr +import pytest +from hypothesis import given +from hypothesis.strategies import text + +from ics.grammar import ContentLine, string_to_container +from ics.valuetype.text import TextConverter + +# Text may be comma-separated multi-value but is never quoted, with the characters [\\;,\n] escaped + +TextConv: TextConverter = TextConverter.INST + + +@pytest.mark.parametrize("inp_esc, out_uesc", [ + ( + "SUMMARY:Project XYZ Final Review\\nConference Room - 3B\\nCome Prepared.", + ContentLine("SUMMARY", value="Project XYZ Final Review\nConference Room - 3B\nCome Prepared.") + ), + ( + "DESCRIPTION;ALTREP=\"cid:part1.0001@example.org\":The Fall'98 Wild Wizards Conference - - Las Vegas\\, NV\\, USA", + ContentLine("DESCRIPTION", {"ALTREP": ["cid:part1.0001@example.org"]}, value="The Fall'98 Wild Wizards Conference - - Las Vegas, NV, USA") + ), + ( + "TEST:abc\\n\\,\\;:\"\t=xyz", + ContentLine("TEST", value="abc\n,;:\"\t=xyz") + ), +]) +def test_example_text_recode(inp_esc, out_uesc): + par_esc = ContentLine.parse(inp_esc) + par_uesc = attr.evolve(par_esc, value=TextConv.parse(par_esc.value)) + out_esc = attr.evolve(out_uesc, value=TextConv.serialize(out_uesc.value)) + assert par_uesc == out_uesc + ser_esc = out_esc.serialize() + assert inp_esc == ser_esc + assert string_to_container(inp_esc) == [par_esc] + + +# TODO list examples ("RESOURCES:EASEL,PROJECTOR,VCR", ContentLine("RESOURCES", value="EASEL,PROJECTOR,VCR")) + +def test_trailing_escape_text(): + with pytest.raises(ValueError) as excinfo: + TextConv.parse("text\\,with\tdangling escape\\") + assert "not end with an escape sequence" in str(excinfo.value) + + assert TextConv.parse("text\\,with\tdangling escape") == "text,with\tdangling escape" + assert TextConv.serialize("text,text\\,with\tdangling escape\\") == "text\\,text\\\\\\,with\tdangling escape\\\\" + + +def test_trailing_escape_value_list(): + cl1 = ContentLine.parse("TEST:this is,a list \\, with a\\\\,trailing escape\\") + with pytest.raises(ValueError) as excinfo: + list(TextConv.split_value_list(cl1.value)) + assert "not end with an escape sequence" in str(excinfo.value) + + cl2 = ContentLine.parse("TEST:this is,a list \\, with a\\\\,trailing escape") + assert list(TextConv.split_value_list(cl2.value)) == \ + ["this is", "a list \\, with a\\\\", "trailing escape"] + assert [TextConv.parse(v) for v in TextConv.split_value_list(cl2.value)] == \ + ["this is", "a list , with a\\", "trailing escape"] + + +@given(value=text()) +def test_any_text_value_recode(value): + esc = TextConv.serialize(value) + assert TextConv.parse(esc) == value + cl = ContentLine("TEST", value=esc) + assert ContentLine.parse(cl.serialize()) == cl + assert string_to_container(cl.serialize()) == [cl] + vals = [esc, esc, "test", esc] + cl2 = ContentLine("TEST", value=TextConv.join_value_list(vals)) + assert list(TextConv.split_value_list(cl2.value)) == vals + assert ContentLine.parse(cl.serialize()) == cl + assert string_to_container(cl.serialize()) == [cl] From c79fd0be28d7fe06f9b0303861d8d27636768d2c Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Fri, 10 Apr 2020 12:39:14 +0200 Subject: [PATCH 11/43] fix handling of quoted params --- ics/grammar/__init__.py | 33 +++++++++++++-------- ics/grammar/contentline.ebnf | 2 +- tests/grammar/__init__.py | 56 ++++++++++++++++++++++++++++-------- tests/valuetype/text.py | 17 +++++++---- 4 files changed, 78 insertions(+), 30 deletions(-) diff --git a/ics/grammar/__init__.py b/ics/grammar/__init__.py index 76440870..d081cfe0 100644 --- a/ics/grammar/__init__.py +++ b/ics/grammar/__init__.py @@ -1,7 +1,8 @@ import functools import re import warnings -from typing import Generator, List, MutableSequence +from collections import UserString +from typing import Generator, List, MutableSequence, Union import attr import importlib_resources # type: ignore @@ -11,6 +12,8 @@ from ics.types import ContainerItem, ExtraParams, RuntimeAttrValidation, copy_extra_params from ics.utils import limit_str_length, next_after_str_escape, validate_truthy +__all__ = ["ParseError", "QuotedParamValue", "ContentLine", "Container", "string_to_container"] + GRAMMAR = tatsu.compile(importlib_resources.read_text(__name__, "contentline.ebnf")) @@ -18,6 +21,10 @@ class ParseError(Exception): pass +class QuotedParamValue(UserString): + pass + + @attr.s class ContentLine(RuntimeAttrValidation): """ @@ -48,7 +55,7 @@ def serialize_iter(self, newline=False): for nr, pval in enumerate(self.params[pname]): if nr > 0: yield "," - if re.search("[:;,]", pval): + if isinstance(pval, QuotedParamValue) or re.search("[:;,]", pval): # Property parameter values that contain the COLON, SEMICOLON, or COMMA character separators # MUST be specified as quoted-string text values. # TODO The DQUOTE character is used as a delimiter for parameter values that contain @@ -82,13 +89,17 @@ def parse(cls, line): @classmethod def interpret_ast(cls, ast): - name = ''.join(ast['name']) - value = ''.join(ast['value']) + name = ast['name'] + value = ast['value'] params = ExtraParams(dict()) for param_ast in ast.get('params', []): - param_name = ''.join(param_ast["name"]) - param_values = [unescape_param(''.join(x)) for x in param_ast["values_"]] - params[param_name] = param_values + param_name = param_ast["name"] + params[param_name] = [] + for param_value_ast in param_ast["values_"]: + val = unescape_param(param_value_ast["value"]) + if param_value_ast["quoted"] == "true": + val = QuotedParamValue(val) + params[param_name].append(val) return cls(name, params, value) def clone(self): @@ -215,8 +226,8 @@ def __getitem__(self, i): reverse = _wrap_list_func(list.reverse) -def escape_param(string: str) -> str: - return string.translate( +def escape_param(string: Union[str, QuotedParamValue]) -> str: + return str(string).translate( {ord("\""): "^'", ord("^"): "^^", ord("\n"): "^n", @@ -249,9 +260,7 @@ def unfold_lines(physical_lines): current_line = '' for line in physical_lines: line = line.rstrip('\r') - if len(line.strip()) == 0: - continue - elif not current_line: + if not current_line: current_line = line elif line[0] in (' ', '\t'): current_line += line[1:] diff --git a/ics/grammar/contentline.ebnf b/ics/grammar/contentline.ebnf index c033b322..5a5224ff 100644 --- a/ics/grammar/contentline.ebnf +++ b/ics/grammar/contentline.ebnf @@ -30,7 +30,7 @@ contentline = name:name {(";" params+:param )}* ":" value:value ; param = name:param_name "=" values+:param_value {("," values+:param_value)}* ; param_name = iana_token | x_name ; -param_value = quoted_string | paramtext ; +param_value = value:quoted_string quoted:`true` | value:paramtext quoted:`false` ; paramtext = SAFE_CHAR_STAR ; value = VALUE_CHAR_STAR ; diff --git a/tests/grammar/__init__.py b/tests/grammar/__init__.py index d602661e..799adb70 100644 --- a/tests/grammar/__init__.py +++ b/tests/grammar/__init__.py @@ -1,11 +1,14 @@ +import re + import pytest from hypothesis import assume, given from hypothesis.strategies import characters, text -from ics.grammar import Container, ContentLine, ParseError, string_to_container, unfold_lines +from ics.grammar import Container, ContentLine, ParseError, QuotedParamValue, escape_param, string_to_container, unfold_lines +CONTROL = [chr(i) for i in range(ord(" ")) if i != ord("\t")] NAME = text(alphabet=(characters(whitelist_categories=["Lu"], whitelist_characters=["-"], max_codepoint=128)), min_size=1) -VALUE = text(characters(blacklist_categories=["Cs"], blacklist_characters=["\n", "\r"])) +VALUE = text(characters(blacklist_categories=["Cs"], blacklist_characters=CONTROL)) @pytest.mark.parametrize("inp, out", [ @@ -26,16 +29,16 @@ ContentLine(name='DTSTART', params={'TZID': ['Europe/Brussels']}, value='20131029T103000') ), ( 'haha;p2=v2;p1=v1:', - ContentLine(name='HAHA', params={'p1': ['v1'], 'p2': ['v2']}, value='') + ContentLine(name='HAHA', params={'p2': ['v2'], 'p1': ['v1']}, value='') ), ( 'haha;hihi=p3,p4,p5;hoho=p1,p2:blabla:blublu', - ContentLine(name='HAHA', params={'hoho': ['p1', 'p2'], 'hihi': ['p3', 'p4', 'p5']}, value='blabla:blublu') + ContentLine(name='HAHA', params={'hihi': ['p3', 'p4', 'p5'], 'hoho': ['p1', 'p2']}, value='blabla:blublu') ), ( 'ATTENDEE;X-A="I&rsquo\\;ll be in NYC":mailto:a@a.com', ContentLine(name='ATTENDEE', params={'X-A': ['I&rsquo\\;ll be in NYC']}, value='mailto:a@a.com') ), ( 'DTEND;TZID="UTC":20190107T000000', - ContentLine(name='DTEND', params={'TZID': ['UTC']}, value='20190107T000000') + ContentLine(name='DTEND', params={'TZID': [QuotedParamValue('UTC')]}, value='20190107T000000') ), ( "ATTENDEE;MEMBER=\"mailto:ietf-calsch@example.org\":mailto:jsmith@example.com", ContentLine("ATTENDEE", {"MEMBER": ["mailto:ietf-calsch@example.org"]}, "mailto:jsmith@example.com") @@ -79,6 +82,26 @@ def test_example_recode(inp, out): assert string_to_container(inp) == [out] +def test_param_quoting(): + inp = 'TEST;P1="A";P2=B;P3=C,"D",E,"F":"VAL"' + out = ContentLine("TEST", { + "P1": [QuotedParamValue("A")], + "P2": ["B"], + "P3": ["C", QuotedParamValue("D"), "E", QuotedParamValue("F")], + }, '"VAL"') + par = ContentLine.parse(inp) + assert par == out + ser = out.serialize() + assert inp == ser + par_ser = par.serialize() + assert inp == par_ser + assert string_to_container(inp) == [out] + + for param in out.params.keys(): + for o_val, p_val in zip(out[param], par[param]): + assert type(o_val) == type(p_val) + + def test_trailing_escape_param(): with pytest.raises(ValueError) as excinfo: ContentLine.parse("TEST;PARAM=this ^^ is a ^'param^',with a ^trailing escape^:value") @@ -96,23 +119,31 @@ def test_any_name_value_recode(name, value): assert string_to_container(raw) == [cl] +def quote_escape_param(pval): + if re.search("[:;,]", pval): + return '"%s"' % escape_param(pval) + else: + return escape_param(pval) + + @given(param=NAME, value=VALUE) def test_any_param_value_recode(param, value): - raw = "TEST;%s=%s:VALUE" % (param, value) + raw = "TEST;%s=%s:VALUE" % (param, quote_escape_param(value)) assert ContentLine.parse(raw).serialize() == raw - cl = ContentLine("TEST", {param: value}, "VALUE") + cl = ContentLine("TEST", {param: [value]}, "VALUE") assert ContentLine.parse(cl.serialize()) == cl assert string_to_container(raw) == [cl] @given(name=NAME, value=VALUE, param1=NAME, p1value=VALUE, - param2=NAME, p2value=VALUE) -def test_any_name_params_value_recode(name, value, param1, p1value, param2, p2value): + param2=NAME, p2value1=VALUE, p2value2=VALUE) +def test_any_name_params_value_recode(name, value, param1, p1value, param2, p2value1, p2value2): assume(param1 != param2) - raw = "%s;%s=%s;%s=%s:%s" % (name, param1, p1value, param2, p2value, value) + raw = "%s;%s=%s;%s=%s,%s:%s" % (name, param1, quote_escape_param(p1value), + param2, quote_escape_param(p2value1), quote_escape_param(p2value2), value) assert ContentLine.parse(raw).serialize() == raw - cl = ContentLine(name, {param1: p1value, param2: p2value}, value) + cl = ContentLine(name, {param1: [p1value], param2: [p2value1, p2value2]}, value) assert ContentLine.parse(cl.serialize()) == cl assert string_to_container(raw) == [cl] @@ -165,6 +196,7 @@ def test_container(): out_shallow.extend([ContentLine("CL3")]) out_shallow.insert(1, ContentLine("CL2")) out_shallow += [ContentLine("CL4")] + assert out_shallow[1:3] == Container("DIFFERENT", [ContentLine("CL2"), ContentLine("CL3")]) assert out_shallow == Container("DIFFERENT", [ContentLine("CL1"), ContentLine("CL2"), ContentLine("CL3"), ContentLine("CL4")]) with pytest.warns(UserWarning, match="not all-uppercase"): @@ -230,7 +262,7 @@ def test_value_characters(): special_chars = ";:,\"^" inp = "TEST;P1={chars};P2={chars},{chars},\"{chars}\",{chars}:{chars}:{chars}{special}".format( chars=chars, special=special_chars) - out = ContentLine("TEST", {"P1": [chars], "P2": [chars, chars, chars, chars]}, + out = ContentLine("TEST", {"P1": [chars], "P2": [chars, chars, QuotedParamValue(chars), chars]}, chars + ":" + chars + special_chars) par = ContentLine.parse(inp) assert par == out diff --git a/tests/valuetype/text.py b/tests/valuetype/text.py index 97fe69fe..36797afe 100644 --- a/tests/valuetype/text.py +++ b/tests/valuetype/text.py @@ -1,12 +1,11 @@ import attr import pytest from hypothesis import given -from hypothesis.strategies import text from ics.grammar import ContentLine, string_to_container from ics.valuetype.text import TextConverter - # Text may be comma-separated multi-value but is never quoted, with the characters [\\;,\n] escaped +from tests.grammar import VALUE TextConv: TextConverter = TextConverter.INST @@ -21,8 +20,8 @@ ContentLine("DESCRIPTION", {"ALTREP": ["cid:part1.0001@example.org"]}, value="The Fall'98 Wild Wizards Conference - - Las Vegas, NV, USA") ), ( - "TEST:abc\\n\\,\\;:\"\t=xyz", - ContentLine("TEST", value="abc\n,;:\"\t=xyz") + "TEST:abc\\r\\n\\,\\;:\"\t=xyz", + ContentLine("TEST", value="abc\r\n,;:\"\t=xyz") ), ]) def test_example_text_recode(inp_esc, out_uesc): @@ -46,6 +45,14 @@ def test_trailing_escape_text(): assert TextConv.serialize("text,text\\,with\tdangling escape\\") == "text\\,text\\\\\\,with\tdangling escape\\\\" +def test_broken_escape(): + with pytest.raises(ValueError) as e: + TextConv.unescape_text("\\t") + assert e.match("can't handle escaped character") + with pytest.raises(ValueError) as e: + TextConv.unescape_text("abc;def") + assert e.match("unescaped character") + def test_trailing_escape_value_list(): cl1 = ContentLine.parse("TEST:this is,a list \\, with a\\\\,trailing escape\\") with pytest.raises(ValueError) as excinfo: @@ -59,7 +66,7 @@ def test_trailing_escape_value_list(): ["this is", "a list , with a\\", "trailing escape"] -@given(value=text()) +@given(value=VALUE) def test_any_text_value_recode(value): esc = TextConv.serialize(value) assert TextConv.parse(esc) == value From aeb575eee7355375229709c0a0e3a76df8c29fbb Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Fri, 10 Apr 2020 14:44:22 +0200 Subject: [PATCH 12/43] migrate repo structure to poetry --- .github/workflows/pythonpackage.yml | 29 + .gitignore | 112 +++ AUTHORS.rst | 58 ++ CHANGELOG.rst | 183 +++++ CONTRIBUTING.rst | 68 ++ LICENSE.rst | 191 +++++ README.rst | 116 ++++ mypy.ini | 8 + poetry.lock | 1000 +++++++++++++++++++++++++++ pyproject.toml | 49 ++ src/ics/__init__.py | 1 + tests/__init__.py | 0 tests/test_ics.py | 5 + tox.ini | 45 ++ 14 files changed, 1865 insertions(+) create mode 100644 .github/workflows/pythonpackage.yml create mode 100644 .gitignore create mode 100644 AUTHORS.rst create mode 100644 CHANGELOG.rst create mode 100644 CONTRIBUTING.rst create mode 100644 LICENSE.rst create mode 100644 README.rst create mode 100644 mypy.ini create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 src/ics/__init__.py create mode 100644 tests/__init__.py create mode 100644 tests/test_ics.py create mode 100644 tox.ini diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml new file mode 100644 index 00000000..8785d217 --- /dev/null +++ b/.github/workflows/pythonpackage.yml @@ -0,0 +1,29 @@ +name: Test + +on: [push] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + max-parallel: 4 + matrix: + python-version: [3.6, 3.7, 3.8] + + steps: + - uses: actions/checkout@master + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip setuptools + pip install -r requirements.txt -r dev/requirements-test.txt + - name: Static checking with mypy + run: | + mypy ics + - name: Run pytest + run: | + python setup.py test diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..ef0e8bfe --- /dev/null +++ b/.gitignore @@ -0,0 +1,112 @@ +/.idea +/venv + +# Created by https://www.gitignore.io/api/python +# Edit at https://www.gitignore.io/?templates=python + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# End of https://www.gitignore.io/api/python diff --git a/AUTHORS.rst b/AUTHORS.rst new file mode 100644 index 00000000..00d38c23 --- /dev/null +++ b/AUTHORS.rst @@ -0,0 +1,58 @@ +Authors +------- + +Ics.py is written and maintained by `Nikita Marchant `_ +with the excellent help of `Niko Fink `_. + + +Other contributors, listed alphabetically, are: + +* `@aureooms `_ +* `@Azhrei `_ +* `@ConnyOnny `_ +* `@danieltellez `_ +* `@davidjb `_ +* `@etnarek `_ +* `@gbovyn `_ +* `@GMLudo `_ +* `@guyzmo `_ +* `@jammon `_ +* `@jessejoe `_ +* `@johnnoone `_ +* `@kayluhb `_ +* `@mrmadcow `_ +* `@Ousret `_ +* `@PascalBru `_ +* `@perette `_ +* `@Philiptpp `_ +* `@prashnts `_ +* `@rkeilty `_ +* `@seants `_ +* `@tgamauf `_ +* `@Timic3 `_ +* `@tomschr `_ +* `@Trii `_ +* `@tomschr `_ +* `@zagnut007 `_ +* `@zuphilip `_ + +Many thanks for your contributions! + +There are also a few modules or functions incorporated from other +authors and projects: + +* ``utils.iso_precision`` includes something like 10 lines of Arrow's, code, + which is written by Chris Smith and under Apache license + + +A big part of the code was written at `UrLab `_, an awesome +hackerspace in `ULB `_ in Brussels, Belgium. +A big part of the code was written at `UrLab `_, an awesome hackerspace in `ULB `_ in Brussels, Belgium. + +Thanks to: + +* `arrow `_ which provides a nice API for dates, times and deltas, +* `python-dateutil `_ for parsing timezones from VTIMEZONE blocks, +* `requests `_ for giving me inspiration from it's beautiful and pythonic API (and its doc too), +* `six `_ to have made the python3 transition easier, +* `#urlab `_ for help and advice. diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 00000000..65261e19 --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,183 @@ +============ +Ics.py changelog +============ + +************** +0.8 (in dev) - Grace Hopper +************** + +This is a major release in the life of ics.py as it fixes a lot of long standing +(design) issues with timespans, removes Arrow and introduces `attrs`. +Thank you @N-Coder for the huge work you put in this! + +In progress: + - Remove Arrow + - Fix all-day issues + - Add attrs + - Fix timezone issues + - Fix SEQUENCE bug + - Introduce Timespan + - `arrow` was removed, use built-in `datetime` and `timedelta` instead + - `events`, `todos`, `attendees` and `alarms` are now all lists instead of sets, as their contained types are not actually hashable and in order to keep the order they had in the file. Use `append` instead of `add` to insert new entries. + - `attendees` and `organizer` now must be instances of the respective classes, plain strings with the e-mail are no longer allowed + - `extra` can now only contain nested `Container`s and `ContentLine`s, no plain strings + - some attributes now have further validators that restrict which values they can be set to, which might further change once we have configurable levels of strictness + - `dtstamp` and `created` have been separated, `dtstamp` is the only one set automatically (hopefully more conforming with the RFC) + - `Event.join` is hard to do right and now gone if nobody needs it (and is able to formulate a clear behaviour faced with floating events vs events in different timezones and also all-day events) + - method `has_end()` -> property `has_explicit_end` as any Event with a begin time has an end + +************** +0.7 - Katherine Johnson +************** + +Special thanks to @N-Coder for making 0.7 happen! + +Breaking changes: + - Remove useless `day` argument from `Timeline.today()` + - Attendee and Organizer attributes are now classes and can not be set to `str`. + +Minor changes: + - Add support for Python 3.8 + - Ensure `VERSION` is the first line of a `VCALENDAR` and `PRODID` is second. + +Bug fixes: + - Fix regression in the support of emojis (and other unicode chars) while + parsing. (Thanks @Azhrei) + - Fix a bug preventing an EmailAlarm to be instantiated + - Fix multiple bugs in Organizer and Attendees properties. + (See #207, #209, #217, #218) + +************** +0.6 +************** + +Major changes: + - Drop support for Python 3.5. Python 3.7 is now distributed in both Ubuntu LTS + and Debian stable, the PSF is providing only security fixes for 3.5. It's time + to move on ! + - Add `竜 TatSu `_ as a dependency. + This enables us to have a real PEG parser and not a combination of + regexes and string splitting. + - The previously private `._unused` is now renamed to public `.extra` and + becomes documented. + - The Alarms have been deeply refactored (see the docs for more detail) and + many bugs have been fixed. + +Minor changes: + - Add mypy + - Add GEO (thanks @johnnoone !) + - `Calendar.parse_multiple()` now accepts streams of multiple calendars. + - `Calendar()` does not accept iterables to be parsed anymore (only a single + string) + - Add support for classification (#177, thanks @PascalBru !) + - Support arrow up to <0.15 + - Cleanup the logic for component parsers/serializers: they are now in their own + files and are registered via the `Meta` class + +Bug fixes: + - Events no longer have the TRANSP property by default (Fixes #190) + - Fix parsing of quoted values as well as escaped semi-columns (#185 and #193) + + +************** +0.5 +************** + +This is the first version to be Python 3 only. + +This release happens a bit more than a year after the previous one and was made to +distribute latest changes to everyone and remove the confusion between master and PyPi. + +Please note that it may contain (lot of) bugs and not be fully polished. +This is still alpha quality software! + +Highlights and breaking changes: + - Drop support for Python 2, support Python from 3.5 to 3.8 + - Upgrade arrow to 0.11 and fix internal call to arrow to specify the string + format (thanks @muffl0n, @e-c-d and @chauffer) + +Additions: + - LAST-MODIFIED attribute support (thanks @Timic3) + - Support for Organizers to Events (thanks @danieltellez and kayluhb) + - Support for Attendees to Events (thanks @danieltellez and kayluhb) + - Support for Event and Todo status (thanks @johnnoone) + +Bug fixes: + - Fix all-day events lasting multiple days by using a DTEND with a date and not a datetime (thanks @raspbeguy) + - Fix off by one error on the DTEND on all day events (issues #92 and #150) + - Fix SEQUENCE in VTIMEZONE error + - Fixed NONE type support for Alarms (thanks @zagnut007) + +Known issues: + - There are known problems with all-day events. This GitHub issue summarizes them + well: https://github.com/C4ptainCrunch/ics.py/issues/155. You can expect them to + be fixed in 0.6 but not before. + +Misc: + - Improve TRIGGER DURATION parsing logic (thanks @jessejoe) + - Event equality now checks all fields (except uid) + - Alarms in Event and Todo are now consistently lists and not a mix between set() and list() + +Thanks also to @t00n, @aureooms, @chauffer, @seants, @davidjb, @xaratustrah, @Philiptpp + +************** +0.4 +************** + +Last version to support Python 2.7 and 3.3. + +This version is by far the one with the most contributors, thank you ! + +Highlights: + - Todo/VTODO support (thanks @tgamauf) + - Add event arithmetics (thanks @guyzmo) + - Support for alarms/`VALARM` (thanks @rkeilty) + - Support for categories (thanks @perette) + +Misc: + - Make the parser work with tabbed whitespace (thanks @mrmadcow) + - Better error messages (thanks @guyzmo) + - Support input with missing `VERSION` (thanks @prashnts) + - Support for Time Transparency/`TRANSP` (thanks @GMLudo) + - All day events not omit the timezone (thanks @Trii) + - Multi-day events fixes (thanks @ConnyOnny) + - Fix `TZID` drop when `VTIMEZONE` is empty (thanks @ConnyOnny) + - Better test coverage (thanks @aureooms) + +Breaking Changes: + - Removed EventList class + +Thank you also to @davidjb, @etnarek, @jammon + +******* +0.3.1 +******* + - Pin arrow to 0.4.2 + +***** +0.3 +***** + - Events in an `EventList()` are now always sorted + - Freeze the version of Arrow (they made backwards-incompatible changes) + - Add a lot of tests + - Lots of small bugfixes + +******* +0.1.3 +******* +- FIX : broken install. Again. + +******* +0.1.2 +******* + - FIX : broken install + +******* +0.1.1 +******* + - FIX : wrong `super()` and add output documentation + +**** +0.1 +**** + - First version diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 00000000..a72be2cc --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,68 @@ +Contributing to ics.py +====================== + +Do you want to contribute? We would love your help 🤗 + +Feel free to submit patches, issues, feature requests, pull requests on the +`GitHub repo `_. + +Please note that ics.py is maintained by volunteers (mostly one volunteer) +on their free time. It might take some time for us to have a look at your +work. + + +How to submit an issue +---------------------- + +Please include the following in your bug reports: + +* the version of ics.py you are using; run ``pip freeze | grep ics`` +* the version of Python ``python -v`` +* the OS you are using + +Please also include a (preferably minimal) example of the code or +the input that causes problem along with the stacktrace if there is one. + +How to submit a pull request +---------------------------- + +First, before writing your PR, please +`open an issue `_, +on GitHub to discuss the problem you want to solve and debate on the way +you are solving it. This might save you a lot of time if the maintainers +are already working on it or have a specific idea on how the problem should +be solved. + +If you are fixing a bug +^^^^^^^^^^^^^^^^^^^^^^^ + +Please add a test and add a link to it in the PR description +proving that the bug is fixed. +This will help us merge your PR quickly and above all, this will make +sure that we won't re-introduce the bug later by mistake. + +If you are adding a feature +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +We will ask you to provide: + +* A few tests showing your feature works as intended (they are also great examples and will prevent regressions) +* Write docstrings on the public API +* Add type annotations where possible +* Think about where and how this will affect documentation and amend + the respective section + +Last thing +^^^^^^^^^^ + +* Please add yourself to ``AUTHORS.rst`` +* and state your changes in ``CHANGELOG.rst``. + +.. note:: + Your PR will most likely be squashed in a single commit, authored + by the maintainer that merged the PR and you will be credited with a + ``Co-authored-by:`` in the commit message (this way GitHub picks up + your contribution). + + The title of your PR will become the commit message, please craft it + with care. diff --git a/LICENSE.rst b/LICENSE.rst new file mode 100644 index 00000000..c8190703 --- /dev/null +++ b/LICENSE.rst @@ -0,0 +1,191 @@ +:orphan: + +.. _`apache2`: + +============== +Apache License +============== + +:Version: 2.0 +:Date: January 2004 +:URL: http://www.apache.org/licenses/ + +------------------------------------------------------------ +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION +------------------------------------------------------------ + +1. Definitions. +--------------- + +**"License"** shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +**"Licensor"** shall mean the copyright owner or entity authorized by the +copyright owner that is granting the License. + +**"Legal Entity"** shall mean the union of the acting entity and all other +entities that control, are controlled by, or are under common control with that +entity. For the purposes of this definition, "control" means *(i)* the power, +direct or indirect, to cause the direction or management of such entity, +whether by contract or otherwise, or *(ii)* ownership of fifty percent (50%) or +more of the outstanding shares, or *(iii)* beneficial ownership of such entity. + +**"You"** (or **"Your"**) shall mean an individual or Legal Entity exercising +permissions granted by this License. + +**"Source"** form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation source, and +configuration files. + +**"Object"** form shall mean any form resulting from mechanical transformation +or translation of a Source form, including but not limited to compiled object +code, generated documentation, and conversions to other media types. + +**"Work"** shall mean the work of authorship, whether in Source or Object form, +made available under the License, as indicated by a copyright notice that is +included in or attached to the work (an example is provided in the Appendix +below). + +**"Derivative Works"** shall mean any work, whether in Source or Object form, +that is based on (or derived from) the Work and for which the editorial +revisions, annotations, elaborations, or other modifications represent, as a +whole, an original work of authorship. For the purposes of this License, +Derivative Works shall not include works that remain separable from, or merely +link (or bind by name) to the interfaces of, the Work and Derivative Works +thereof. + +**"Contribution"** shall mean any work of authorship, including the original +version of the Work and any modifications or additions to that Work or +Derivative Works thereof, that is intentionally submitted to Licensor for +inclusion in the Work by the copyright owner or by an individual or Legal +Entity authorized to submit on behalf of the copyright owner. For the purposes +of this definition, "submitted" means any form of electronic, verbal, or +written communication sent to the Licensor or its representatives, including +but not limited to communication on electronic mailing lists, source code +control systems, and issue tracking systems that are managed by, or on behalf +of, the Licensor for the purpose of discussing and improving the Work, but +excluding communication that is conspicuously marked or otherwise designated in +writing by the copyright owner as "Not a Contribution." + +**"Contributor"** shall mean Licensor and any individual or Legal Entity on +behalf of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +2. Grant of Copyright License. +------------------------------ + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and +such Derivative Works in Source or Object form. + +3. Grant of Patent License. +--------------------------- + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. +------------------ + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +- You must give any other recipients of the Work or Derivative Works a copy of + this License; and + +- You must cause any modified files to carry prominent notices stating that You + changed the files; and + +- You must retain, in the Source form of any Derivative Works that You + distribute, all copyright, patent, trademark, and attribution notices from + the Source form of the Work, excluding those notices that do not pertain to + any part of the Derivative Works; and + +- If the Work includes a ``"NOTICE"`` text file as part of its distribution, + then any Derivative Works that You distribute must include a readable copy of + the attribution notices contained within such ``NOTICE`` file, excluding + those notices that do not pertain to any part of the Derivative Works, in at + least one of the following places: within a ``NOTICE`` text file distributed + as part of the Derivative Works; within the Source form or documentation, if + provided along with the Derivative Works; or, within a display generated by + the Derivative Works, if and wherever such third-party notices normally + appear. The contents of the ``NOTICE`` file are for informational purposes + only and do not modify the License. You may add Your own attribution notices + within Derivative Works that You distribute, alongside or as an addendum to + the ``NOTICE`` text from the Work, provided that such additional attribution + notices cannot be construed as modifying the License. You may add Your own + copyright statement to Your modifications and may provide additional or + different license terms and conditions for use, reproduction, or distribution + of Your modifications, or for any such Derivative Works as a whole, provided + Your use, reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. +------------------------------- + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms +of any separate license agreement you may have executed with Licensor regarding +such Contributions. + +6. Trademarks. +-------------- + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the ``NOTICE`` file. + +7. Disclaimer of Warranty. +-------------------------- + +Unless required by applicable law or agreed to in writing, Licensor provides +the Work (and each Contributor provides its Contributions) on an **"AS IS" +BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND**, either express or +implied, including, without limitation, any warranties or conditions of +**TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR +PURPOSE**. You are solely responsible for determining the appropriateness of +using or redistributing the Work and assume any risks associated with Your +exercise of permissions under this License. + +8. Limitation of Liability. +--------------------------- + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License +or out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, +or any and all other commercial damages or losses), even if such Contributor +has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. +---------------------------------------------- + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. +However, in accepting such obligations, You may act only on Your own behalf and +on Your sole responsibility, not on behalf of any other Contributor, and only +if You agree to indemnify, defend, and hold each Contributor harmless for any +liability incurred by, or claims asserted against, such Contributor by reason +of your accepting any such warranty or additional liability. + +**END OF TERMS AND CONDITIONS** diff --git a/README.rst b/README.rst new file mode 100644 index 00000000..e9ac08ab --- /dev/null +++ b/README.rst @@ -0,0 +1,116 @@ +Ics.py : iCalendar for Humans +============================= + +`Original repository `_ (GitHub) - +`Bugtracker and issues `_ (GitHub) - +`PyPi package `_ (ics) - +`Documentation `_ (Read The Docs). + + +.. image:: https://img.shields.io/github/license/c4ptaincrunch/ics.py.svg + :target: https://pypi.python.org/pypi/ics/ + :alt: Apache 2 License + + +Ics.py is a pythonic and easy iCalendar library. +Its goals are to read and write ics data in a developer friendly way. + +iCalendar is a widely-used and useful format but not user friendly. +Ics.py is there to give you the ability of creating and reading this +format without any knowledge of it. + +It should be able to parse every calendar that respects the +`rfc5545 `_ and maybe some more… +It also outputs rfc compliant calendars. + +iCalendar (file extension `.ics`) is used by Google Calendar, +Apple Calendar, Android and many more. + + +Ics.py is available for Python>=3.6 and is Apache2 Licensed. + + + +Quickstart +---------- + +.. code-block:: bash + + $ pip install ics + + + +.. code-block:: python + + from ics import Calendar, Event + c = Calendar() + e = Event() + e.name = "My cool event" + e.begin = '2014-01-01 00:00:00' + c.events.add(e) + c.events + # [] + with open('my.ics', 'w') as my_file: + my_file.writelines(c) + # and it's done ! + +More examples are available in the +`documentation `_. + +Documentation +------------- + +All the `documentation `_ is hosted on +`readthedocs.org `_ and is updated automatically +at every commit. + +* `Quickstart `_ +* `API `_ +* `About `_ + + +Contribute +---------- + +Contribution are welcome of course! For more information, see +`contributing `_. + + +Testing & Docs +-------------- + +.. code-block:: bash + + # setup virtual environment + $ sudo pip install virtualenv + $ virtualenv ve + $ source ve/bin/activate + + # tests + $ pip install -r requirements.txt + $ pip install -r dev/requirements-test.txt + $ python setup.py test + + # tests coverage + $ pip install -r requirements.txt + $ pip install -r dev/requirements-test.txt + $ python setup.py test + $ coverage html + $ firefox htmlcov/index.html + + # docs + $ pip install -r requirements.txt + $ pip install -r dev/requirements-doc.txt + $ cd doc + $ make html + + +Links +----- +* `rfc5545 `_ +* `Vulgarised RFC `_ + +.. image:: http://i.imgur.com/KnSQg48.jpg + :target: https://github.com/C4ptainCrunch/ics.py + :alt: Parse ALL the calendars! + :align: center diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000..08d9407a --- /dev/null +++ b/mypy.ini @@ -0,0 +1,8 @@ +[mypy] +python_version = 3.6 +warn_return_any = True +warn_unused_configs = True +disallow_untyped_defs = True + +[mypy-tests.*] +ignore_errors = True diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 00000000..d268bcc5 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1000 @@ +[[package]] +category = "dev" +description = "A configurable sidebar-enabled Sphinx theme" +name = "alabaster" +optional = false +python-versions = "*" +version = "0.7.12" + +[[package]] +category = "dev" +description = "apipkg: namespace control and lazy-import mechanism" +name = "apipkg" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.5" + +[[package]] +category = "dev" +description = "Atomic file writes." +marker = "python_version >= \"3.5\" and sys_platform == \"win32\" or sys_platform == \"win32\"" +name = "atomicwrites" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.3.0" + +[[package]] +category = "main" +description = "Classes Without Boilerplate" +name = "attrs" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "19.3.0" + +[package.extras] +azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"] +dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"] +docs = ["sphinx", "zope.interface"] +tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] + +[[package]] +category = "dev" +description = "Internationalization utilities" +name = "babel" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.8.0" + +[package.dependencies] +pytz = ">=2015.7" + +[[package]] +category = "dev" +description = "Python package for providing Mozilla's CA Bundle." +name = "certifi" +optional = false +python-versions = "*" +version = "2020.4.5.1" + +[[package]] +category = "dev" +description = "Universal encoding detector for Python 2 and 3" +name = "chardet" +optional = false +python-versions = "*" +version = "3.0.4" + +[[package]] +category = "dev" +description = "Cross-platform colored terminal text." +marker = "python_version >= \"3.5\" and sys_platform == \"win32\" or sys_platform == \"win32\"" +name = "colorama" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "0.4.3" + +[[package]] +category = "dev" +description = "Code coverage measurement for Python" +name = "coverage" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" +version = "5.0.4" + +[package.extras] +toml = ["toml"] + +[[package]] +category = "dev" +description = "Docutils -- Python Documentation Utilities" +name = "docutils" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "0.16" + +[[package]] +category = "dev" +description = "execnet: rapid multi-Python deployment" +name = "execnet" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.7.1" + +[package.dependencies] +apipkg = ">=1.4" + +[package.extras] +testing = ["pre-commit"] + +[[package]] +category = "dev" +description = "A platform independent file lock." +name = "filelock" +optional = false +python-versions = "*" +version = "3.0.12" + +[[package]] +category = "dev" +description = "A library for property-based testing" +name = "hypothesis" +optional = false +python-versions = ">=3.5.2" +version = "5.8.0" + +[package.dependencies] +attrs = ">=19.2.0" +sortedcontainers = ">=2.1.0,<3.0.0" + +[package.extras] +all = ["django (>=1.11)", "dpcontracts (>=0.4)", "lark-parser (>=0.6.5)", "numpy (>=1.9.0)", "pandas (>=0.19)", "pytest (>=4.3)", "python-dateutil (>=1.4)", "pytz (>=2014.1)"] +dateutil = ["python-dateutil (>=1.4)"] +django = ["pytz (>=2014.1)", "django (>=1.11)"] +dpcontracts = ["dpcontracts (>=0.4)"] +lark = ["lark-parser (>=0.6.5)"] +numpy = ["numpy (>=1.9.0)"] +pandas = ["pandas (>=0.19)"] +pytest = ["pytest (>=4.3)"] +pytz = ["pytz (>=2014.1)"] + +[[package]] +category = "dev" +description = "Internationalized Domain Names in Applications (IDNA)" +name = "idna" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.9" + +[[package]] +category = "dev" +description = "Getting image size from png/jpeg/jpeg2000/gif file" +name = "imagesize" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.2.0" + +[[package]] +category = "main" +description = "Read metadata from Python packages" +marker = "python_version >= \"3.5\" and python_version < \"3.8\" or python_version < \"3.8\"" +name = "importlib-metadata" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +version = "1.6.0" + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +docs = ["sphinx", "rst.linker"] +testing = ["packaging", "importlib-resources"] + +[[package]] +category = "main" +description = "Read resources from Python packages" +name = "importlib-resources" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +version = "1.4.0" + +[package.dependencies] +[package.dependencies.importlib-metadata] +python = "<3.8" +version = "*" + +[package.dependencies.zipp] +python = "<3.8" +version = ">=0.4" + +[package.extras] +docs = ["sphinx", "rst.linker", "jaraco.packaging"] + +[[package]] +category = "dev" +description = "A very fast and expressive template engine." +name = "jinja2" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "2.11.1" + +[package.dependencies] +MarkupSafe = ">=0.23" + +[package.extras] +i18n = ["Babel (>=0.8)"] + +[[package]] +category = "dev" +description = "Safely add untrusted strings to HTML/XML markup." +name = "markupsafe" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" +version = "1.1.1" + +[[package]] +category = "dev" +description = "More routines for operating on iterables, beyond itertools" +name = "more-itertools" +optional = false +python-versions = ">=3.5" +version = "8.2.0" + +[[package]] +category = "dev" +description = "Optional static typing for Python" +name = "mypy" +optional = false +python-versions = ">=3.5" +version = "0.770" + +[package.dependencies] +mypy-extensions = ">=0.4.3,<0.5.0" +typed-ast = ">=1.4.0,<1.5.0" +typing-extensions = ">=3.7.4" + +[package.extras] +dmypy = ["psutil (>=4.0)"] + +[[package]] +category = "dev" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +name = "mypy-extensions" +optional = false +python-versions = "*" +version = "0.4.3" + +[[package]] +category = "dev" +description = "Core utilities for Python packages" +name = "packaging" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "20.3" + +[package.dependencies] +pyparsing = ">=2.0.2" +six = "*" + +[[package]] +category = "dev" +description = "Python style guide checker" +name = "pep8" +optional = false +python-versions = "*" +version = "1.7.1" + +[[package]] +category = "dev" +description = "plugin and hook calling mechanisms for python" +name = "pluggy" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "0.13.1" + +[package.dependencies] +[package.dependencies.importlib-metadata] +python = "<3.8" +version = ">=0.12" + +[package.extras] +dev = ["pre-commit", "tox"] + +[[package]] +category = "dev" +description = "A collection of helpful Python tools!" +name = "pockets" +optional = false +python-versions = "*" +version = "0.9.1" + +[package.dependencies] +six = ">=1.5.2" + +[[package]] +category = "dev" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +name = "py" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.8.1" + +[[package]] +category = "dev" +description = "passive checker of Python programs" +name = "pyflakes" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.2.0" + +[[package]] +category = "dev" +description = "Pygments is a syntax highlighting package written in Python." +name = "pygments" +optional = false +python-versions = ">=3.5" +version = "2.6.1" + +[[package]] +category = "dev" +description = "Python parsing module" +name = "pyparsing" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +version = "2.4.7" + +[[package]] +category = "dev" +description = "pytest: simple powerful testing with Python" +name = "pytest" +optional = false +python-versions = ">=3.5" +version = "5.4.1" + +[package.dependencies] +atomicwrites = ">=1.0" +attrs = ">=17.4.0" +colorama = "*" +more-itertools = ">=4.0.0" +packaging = "*" +pluggy = ">=0.12,<1.0" +py = ">=1.5.0" +wcwidth = "*" + +[package.dependencies.importlib-metadata] +python = "<3.8" +version = ">=0.12" + +[package.extras] +checkqa-mypy = ["mypy (v0.761)"] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] + +[[package]] +category = "dev" +description = "pytest plugin with mechanisms for caching across test runs" +name = "pytest-cache" +optional = false +python-versions = "*" +version = "1.0" + +[package.dependencies] +execnet = ">=1.1.dev1" +pytest = ">=2.2" + +[[package]] +category = "dev" +description = "Pytest plugin for measuring coverage." +name = "pytest-cov" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.8.1" + +[package.dependencies] +coverage = ">=4.4" +pytest = ">=3.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests (2.0.2)", "six", "virtualenv"] + +[[package]] +category = "dev" +description = "pytest plugin to check source code with pyflakes" +name = "pytest-flakes" +optional = false +python-versions = "*" +version = "4.0.0" + +[package.dependencies] +pyflakes = "*" +pytest = ">=2.8.0" + +[[package]] +category = "dev" +description = "Mypy static type checker plugin for Pytest" +name = "pytest-mypy" +optional = false +python-versions = "~=3.4" +version = "0.6.1" + +[package.dependencies] +filelock = ">=3.0" + +[[package.dependencies.mypy]] +python = ">=3.5,<3.8" +version = ">=0.500" + +[[package.dependencies.mypy]] +python = ">=3.8" +version = ">=0.700" + +[package.dependencies.pytest] +python = ">=3.5" +version = ">=3.5" + +[[package]] +category = "dev" +description = "pytest plugin to check PEP8 requirements" +name = "pytest-pep8" +optional = false +python-versions = "*" +version = "1.0.6" + +[package.dependencies] +pep8 = ">=1.3" +pytest = ">=2.4.2" +pytest-cache = "*" + +[[package]] +category = "main" +description = "Extensions to the standard Python datetime module" +name = "python-dateutil" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +version = "2.8.1" + +[package.dependencies] +six = ">=1.5" + +[[package]] +category = "dev" +description = "World timezone definitions, modern and historical" +name = "pytz" +optional = false +python-versions = "*" +version = "2019.3" + +[[package]] +category = "dev" +description = "Python HTTP for Humans." +name = "requests" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "2.23.0" + +[package.dependencies] +certifi = ">=2017.4.17" +chardet = ">=3.0.2,<4" +idna = ">=2.5,<3" +urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" + +[package.extras] +security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] +socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] + +[[package]] +category = "main" +description = "Python 2 and 3 compatibility utilities" +name = "six" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +version = "1.14.0" + +[[package]] +category = "dev" +description = "This package provides 26 stemmers for 25 languages generated from Snowball algorithms." +name = "snowballstemmer" +optional = false +python-versions = "*" +version = "2.0.0" + +[[package]] +category = "dev" +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" +name = "sortedcontainers" +optional = false +python-versions = "*" +version = "2.1.0" + +[[package]] +category = "dev" +description = "Python documentation generator" +name = "sphinx" +optional = false +python-versions = ">=3.5" +version = "3.0.0" + +[package.dependencies] +Jinja2 = ">=2.3" +Pygments = ">=2.0" +alabaster = ">=0.7,<0.8" +babel = ">=1.3" +colorama = ">=0.3.5" +docutils = ">=0.12" +imagesize = "*" +packaging = "*" +requests = ">=2.5.0" +setuptools = "*" +snowballstemmer = ">=1.1" +sphinxcontrib-applehelp = "*" +sphinxcontrib-devhelp = "*" +sphinxcontrib-htmlhelp = "*" +sphinxcontrib-jsmath = "*" +sphinxcontrib-qthelp = "*" +sphinxcontrib-serializinghtml = "*" + +[package.extras] +docs = ["sphinxcontrib-websupport"] +lint = ["flake8 (>=3.5.0)", "flake8-import-order", "mypy (>=0.770)", "docutils-stubs"] +test = ["pytest", "pytest-cov", "html5lib", "typed-ast", "cython"] + +[[package]] +category = "dev" +description = "Type hints (PEP 484) support for the Sphinx autodoc extension" +name = "sphinx-autodoc-typehints" +optional = false +python-versions = ">=3.5.2" +version = "1.10.3" + +[package.dependencies] +Sphinx = ">=2.1" + +[package.extras] +test = ["pytest (>=3.1.0)", "typing-extensions (>=3.5)", "sphobjinv (>=2.0)", "dataclasses"] +type_comments = ["typed-ast (>=1.4.0)"] + +[[package]] +category = "dev" +description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books" +name = "sphinxcontrib-applehelp" +optional = false +python-versions = ">=3.5" +version = "1.0.2" + +[package.extras] +lint = ["flake8", "mypy", "docutils-stubs"] +test = ["pytest"] + +[[package]] +category = "dev" +description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." +name = "sphinxcontrib-devhelp" +optional = false +python-versions = ">=3.5" +version = "1.0.2" + +[package.extras] +lint = ["flake8", "mypy", "docutils-stubs"] +test = ["pytest"] + +[[package]] +category = "dev" +description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" +name = "sphinxcontrib-htmlhelp" +optional = false +python-versions = ">=3.5" +version = "1.0.3" + +[package.extras] +lint = ["flake8", "mypy", "docutils-stubs"] +test = ["pytest", "html5lib"] + +[[package]] +category = "dev" +description = "A sphinx extension which renders display math in HTML via JavaScript" +name = "sphinxcontrib-jsmath" +optional = false +python-versions = ">=3.5" +version = "1.0.1" + +[package.extras] +test = ["pytest", "flake8", "mypy"] + +[[package]] +category = "dev" +description = "Sphinx \"napoleon\" extension." +name = "sphinxcontrib-napoleon" +optional = false +python-versions = "*" +version = "0.7" + +[package.dependencies] +pockets = ">=0.3" +six = ">=1.5.2" + +[[package]] +category = "dev" +description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." +name = "sphinxcontrib-qthelp" +optional = false +python-versions = ">=3.5" +version = "1.0.3" + +[package.extras] +lint = ["flake8", "mypy", "docutils-stubs"] +test = ["pytest"] + +[[package]] +category = "dev" +description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." +name = "sphinxcontrib-serializinghtml" +optional = false +python-versions = ">=3.5" +version = "1.1.4" + +[package.extras] +lint = ["flake8", "mypy", "docutils-stubs"] +test = ["pytest"] + +[[package]] +category = "main" +description = "TatSu takes a grammar in a variation of EBNF as input, and outputs a memoizing PEG/Packrat parser in Python." +name = "tatsu" +optional = false +python-versions = "*" +version = "4.4.0" + +[package.extras] +future-regex = ["regex"] + +[[package]] +category = "dev" +description = "a fork of Python 2 and 3 ast modules with type comment support" +name = "typed-ast" +optional = false +python-versions = "*" +version = "1.4.1" + +[[package]] +category = "dev" +description = "Backported and Experimental Type Hints for Python 3.5+" +name = "typing-extensions" +optional = false +python-versions = "*" +version = "3.7.4.2" + +[[package]] +category = "dev" +description = "HTTP library with thread-safe connection pooling, file post, and more." +name = "urllib3" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" +version = "1.25.8" + +[package.extras] +brotli = ["brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] + +[[package]] +category = "dev" +description = "Measures number of Terminal column cells of wide-character codes" +name = "wcwidth" +optional = false +python-versions = "*" +version = "0.1.9" + +[[package]] +category = "main" +description = "Backport of pathlib-compatible object wrapper for zip files" +marker = "python_version >= \"3.5\" and python_version < \"3.8\" or python_version < \"3.8\"" +name = "zipp" +optional = false +python-versions = ">=3.6" +version = "3.1.0" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] +testing = ["jaraco.itertools", "func-timeout"] + +[metadata] +content-hash = "e7eb35d0b51c26096fd35f965980fe1775137f3ee8282686b5fbd1a90e7e2869" +python-versions = "^3.7" + +[metadata.files] +alabaster = [ + {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"}, + {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, +] +apipkg = [ + {file = "apipkg-1.5-py2.py3-none-any.whl", hash = "sha256:58587dd4dc3daefad0487f6d9ae32b4542b185e1c36db6993290e7c41ca2b47c"}, + {file = "apipkg-1.5.tar.gz", hash = "sha256:37228cda29411948b422fae072f57e31d3396d2ee1c9783775980ee9c9990af6"}, +] +atomicwrites = [ + {file = "atomicwrites-1.3.0-py2.py3-none-any.whl", hash = "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4"}, + {file = "atomicwrites-1.3.0.tar.gz", hash = "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"}, +] +attrs = [ + {file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"}, + {file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"}, +] +babel = [ + {file = "Babel-2.8.0-py2.py3-none-any.whl", hash = "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4"}, + {file = "Babel-2.8.0.tar.gz", hash = "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38"}, +] +certifi = [ + {file = "certifi-2020.4.5.1-py2.py3-none-any.whl", hash = "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304"}, + {file = "certifi-2020.4.5.1.tar.gz", hash = "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519"}, +] +chardet = [ + {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, + {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, +] +colorama = [ + {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, + {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"}, +] +coverage = [ + {file = "coverage-5.0.4-cp27-cp27m-macosx_10_12_x86_64.whl", hash = "sha256:8a620767b8209f3446197c0e29ba895d75a1e272a36af0786ec70fe7834e4307"}, + {file = "coverage-5.0.4-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:73aa6e86034dad9f00f4bbf5a666a889d17d79db73bc5af04abd6c20a014d9c8"}, + {file = "coverage-5.0.4-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:408ce64078398b2ee2ec08199ea3fcf382828d2f8a19c5a5ba2946fe5ddc6c31"}, + {file = "coverage-5.0.4-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:cda33311cb9fb9323958a69499a667bd728a39a7aa4718d7622597a44c4f1441"}, + {file = "coverage-5.0.4-cp27-cp27m-win32.whl", hash = "sha256:5f587dfd83cb669933186661a351ad6fc7166273bc3e3a1531ec5c783d997aac"}, + {file = "coverage-5.0.4-cp27-cp27m-win_amd64.whl", hash = "sha256:9fad78c13e71546a76c2f8789623eec8e499f8d2d799f4b4547162ce0a4df435"}, + {file = "coverage-5.0.4-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:2e08c32cbede4a29e2a701822291ae2bc9b5220a971bba9d1e7615312efd3037"}, + {file = "coverage-5.0.4-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:922fb9ef2c67c3ab20e22948dcfd783397e4c043a5c5fa5ff5e9df5529074b0a"}, + {file = "coverage-5.0.4-cp35-cp35m-macosx_10_12_x86_64.whl", hash = "sha256:c3fc325ce4cbf902d05a80daa47b645d07e796a80682c1c5800d6ac5045193e5"}, + {file = "coverage-5.0.4-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:046a1a742e66d065d16fb564a26c2a15867f17695e7f3d358d7b1ad8a61bca30"}, + {file = "coverage-5.0.4-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:6ad6ca45e9e92c05295f638e78cd42bfaaf8ee07878c9ed73e93190b26c125f7"}, + {file = "coverage-5.0.4-cp35-cp35m-win32.whl", hash = "sha256:eda55e6e9ea258f5e4add23bcf33dc53b2c319e70806e180aecbff8d90ea24de"}, + {file = "coverage-5.0.4-cp35-cp35m-win_amd64.whl", hash = "sha256:4a8a259bf990044351baf69d3b23e575699dd60b18460c71e81dc565f5819ac1"}, + {file = "coverage-5.0.4-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:f372cdbb240e09ee855735b9d85e7f50730dcfb6296b74b95a3e5dea0615c4c1"}, + {file = "coverage-5.0.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a37c6233b28e5bc340054cf6170e7090a4e85069513320275a4dc929144dccf0"}, + {file = "coverage-5.0.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:443be7602c790960b9514567917af538cac7807a7c0c0727c4d2bbd4014920fd"}, + {file = "coverage-5.0.4-cp36-cp36m-win32.whl", hash = "sha256:165a48268bfb5a77e2d9dbb80de7ea917332a79c7adb747bd005b3a07ff8caf0"}, + {file = "coverage-5.0.4-cp36-cp36m-win_amd64.whl", hash = "sha256:0a907199566269e1cfa304325cc3b45c72ae341fbb3253ddde19fa820ded7a8b"}, + {file = "coverage-5.0.4-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:513e6526e0082c59a984448f4104c9bf346c2da9961779ede1fc458e8e8a1f78"}, + {file = "coverage-5.0.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:3844c3dab800ca8536f75ae89f3cf566848a3eb2af4d9f7b1103b4f4f7a5dad6"}, + {file = "coverage-5.0.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:641e329e7f2c01531c45c687efcec8aeca2a78a4ff26d49184dce3d53fc35014"}, + {file = "coverage-5.0.4-cp37-cp37m-win32.whl", hash = "sha256:db1d4e38c9b15be1521722e946ee24f6db95b189d1447fa9ff18dd16ba89f732"}, + {file = "coverage-5.0.4-cp37-cp37m-win_amd64.whl", hash = "sha256:62061e87071497951155cbccee487980524d7abea647a1b2a6eb6b9647df9006"}, + {file = "coverage-5.0.4-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:65a7e00c00472cd0f59ae09d2fb8a8aaae7f4a0cf54b2b74f3138d9f9ceb9cb2"}, + {file = "coverage-5.0.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1f66cf263ec77af5b8fe14ef14c5e46e2eb4a795ac495ad7c03adc72ae43fafe"}, + {file = "coverage-5.0.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:85596aa5d9aac1bf39fe39d9fa1051b0f00823982a1de5766e35d495b4a36ca9"}, + {file = "coverage-5.0.4-cp38-cp38-win32.whl", hash = "sha256:86a0ea78fd851b313b2e712266f663e13b6bc78c2fb260b079e8b67d970474b1"}, + {file = "coverage-5.0.4-cp38-cp38-win_amd64.whl", hash = "sha256:03f630aba2b9b0d69871c2e8d23a69b7fe94a1e2f5f10df5049c0df99db639a0"}, + {file = "coverage-5.0.4-cp39-cp39-win32.whl", hash = "sha256:7c9762f80a25d8d0e4ab3cb1af5d9dffbddb3ee5d21c43e3474c84bf5ff941f7"}, + {file = "coverage-5.0.4-cp39-cp39-win_amd64.whl", hash = "sha256:4482f69e0701139d0f2c44f3c395d1d1d37abd81bfafbf9b6efbe2542679d892"}, + {file = "coverage-5.0.4.tar.gz", hash = "sha256:1b60a95fc995649464e0cd48cecc8288bac5f4198f21d04b8229dc4097d76823"}, +] +docutils = [ + {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"}, + {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, +] +execnet = [ + {file = "execnet-1.7.1-py2.py3-none-any.whl", hash = "sha256:d4efd397930c46415f62f8a31388d6be4f27a91d7550eb79bc64a756e0056547"}, + {file = "execnet-1.7.1.tar.gz", hash = "sha256:cacb9df31c9680ec5f95553976c4da484d407e85e41c83cb812aa014f0eddc50"}, +] +filelock = [ + {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, + {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, +] +hypothesis = [ + {file = "hypothesis-5.8.0-py3-none-any.whl", hash = "sha256:84671369a278088f1d48f7ed2aca7975550344fa744783fe6cb84ad5f3f55ff2"}, + {file = "hypothesis-5.8.0.tar.gz", hash = "sha256:6023d9112ac23502abcb20ca3f336096fe97abab86e589cd9bf9b4bfcaa335d7"}, +] +idna = [ + {file = "idna-2.9-py2.py3-none-any.whl", hash = "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"}, + {file = "idna-2.9.tar.gz", hash = "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"}, +] +imagesize = [ + {file = "imagesize-1.2.0-py2.py3-none-any.whl", hash = "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1"}, + {file = "imagesize-1.2.0.tar.gz", hash = "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"}, +] +importlib-metadata = [ + {file = "importlib_metadata-1.6.0-py2.py3-none-any.whl", hash = "sha256:2a688cbaa90e0cc587f1df48bdc97a6eadccdcd9c35fb3f976a09e3b5016d90f"}, + {file = "importlib_metadata-1.6.0.tar.gz", hash = "sha256:34513a8a0c4962bc66d35b359558fd8a5e10cd472d37aec5f66858addef32c1e"}, +] +importlib-resources = [ + {file = "importlib_resources-1.4.0-py2.py3-none-any.whl", hash = "sha256:dd98ceeef3f5ad2ef4cc287b8586da4ebad15877f351e9688987ad663a0a29b8"}, + {file = "importlib_resources-1.4.0.tar.gz", hash = "sha256:4019b6a9082d8ada9def02bece4a76b131518866790d58fdda0b5f8c603b36c2"}, +] +jinja2 = [ + {file = "Jinja2-2.11.1-py2.py3-none-any.whl", hash = "sha256:b0eaf100007721b5c16c1fc1eecb87409464edc10469ddc9a22a27a99123be49"}, + {file = "Jinja2-2.11.1.tar.gz", hash = "sha256:93187ffbc7808079673ef52771baa950426fd664d3aad1d0fa3e95644360e250"}, +] +markupsafe = [ + {file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-win32.whl", hash = "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-win_amd64.whl", hash = "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e"}, + {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f"}, + {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-win32.whl", hash = "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-win_amd64.whl", hash = "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-win32.whl", hash = "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, + {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, +] +more-itertools = [ + {file = "more-itertools-8.2.0.tar.gz", hash = "sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507"}, + {file = "more_itertools-8.2.0-py3-none-any.whl", hash = "sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c"}, +] +mypy = [ + {file = "mypy-0.770-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:a34b577cdf6313bf24755f7a0e3f3c326d5c1f4fe7422d1d06498eb25ad0c600"}, + {file = "mypy-0.770-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:86c857510a9b7c3104cf4cde1568f4921762c8f9842e987bc03ed4f160925754"}, + {file = "mypy-0.770-cp35-cp35m-win_amd64.whl", hash = "sha256:a8ffcd53cb5dfc131850851cc09f1c44689c2812d0beb954d8138d4f5fc17f65"}, + {file = "mypy-0.770-cp36-cp36m-macosx_10_6_x86_64.whl", hash = "sha256:7687f6455ec3ed7649d1ae574136835a4272b65b3ddcf01ab8704ac65616c5ce"}, + {file = "mypy-0.770-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:3beff56b453b6ef94ecb2996bea101a08f1f8a9771d3cbf4988a61e4d9973761"}, + {file = "mypy-0.770-cp36-cp36m-win_amd64.whl", hash = "sha256:15b948e1302682e3682f11f50208b726a246ab4e6c1b39f9264a8796bb416aa2"}, + {file = "mypy-0.770-cp37-cp37m-macosx_10_6_x86_64.whl", hash = "sha256:b90928f2d9eb2f33162405f32dde9f6dcead63a0971ca8a1b50eb4ca3e35ceb8"}, + {file = "mypy-0.770-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:c56ffe22faa2e51054c5f7a3bc70a370939c2ed4de308c690e7949230c995913"}, + {file = "mypy-0.770-cp37-cp37m-win_amd64.whl", hash = "sha256:8dfb69fbf9f3aeed18afffb15e319ca7f8da9642336348ddd6cab2713ddcf8f9"}, + {file = "mypy-0.770-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:219a3116ecd015f8dca7b5d2c366c973509dfb9a8fc97ef044a36e3da66144a1"}, + {file = "mypy-0.770-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7ec45a70d40ede1ec7ad7f95b3c94c9cf4c186a32f6bacb1795b60abd2f9ef27"}, + {file = "mypy-0.770-cp38-cp38-win_amd64.whl", hash = "sha256:f91c7ae919bbc3f96cd5e5b2e786b2b108343d1d7972ea130f7de27fdd547cf3"}, + {file = "mypy-0.770-py3-none-any.whl", hash = "sha256:3b1fc683fb204c6b4403a1ef23f0b1fac8e4477091585e0c8c54cbdf7d7bb164"}, + {file = "mypy-0.770.tar.gz", hash = "sha256:8a627507ef9b307b46a1fea9513d5c98680ba09591253082b4c48697ba05a4ae"}, +] +mypy-extensions = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, +] +packaging = [ + {file = "packaging-20.3-py2.py3-none-any.whl", hash = "sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752"}, + {file = "packaging-20.3.tar.gz", hash = "sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3"}, +] +pep8 = [ + {file = "pep8-1.7.1-py2.py3-none-any.whl", hash = "sha256:b22cfae5db09833bb9bd7c8463b53e1a9c9b39f12e304a8d0bba729c501827ee"}, + {file = "pep8-1.7.1.tar.gz", hash = "sha256:fe249b52e20498e59e0b5c5256aa52ee99fc295b26ec9eaa85776ffdb9fe6374"}, +] +pluggy = [ + {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, + {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, +] +pockets = [ + {file = "pockets-0.9.1-py2.py3-none-any.whl", hash = "sha256:68597934193c08a08eb2bf6a1d85593f627c22f9b065cc727a4f03f669d96d86"}, + {file = "pockets-0.9.1.tar.gz", hash = "sha256:9320f1a3c6f7a9133fe3b571f283bcf3353cd70249025ae8d618e40e9f7e92b3"}, +] +py = [ + {file = "py-1.8.1-py2.py3-none-any.whl", hash = "sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0"}, + {file = "py-1.8.1.tar.gz", hash = "sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa"}, +] +pyflakes = [ + {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, + {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, +] +pygments = [ + {file = "Pygments-2.6.1-py3-none-any.whl", hash = "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324"}, + {file = "Pygments-2.6.1.tar.gz", hash = "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44"}, +] +pyparsing = [ + {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, + {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, +] +pytest = [ + {file = "pytest-5.4.1-py3-none-any.whl", hash = "sha256:0e5b30f5cb04e887b91b1ee519fa3d89049595f428c1db76e73bd7f17b09b172"}, + {file = "pytest-5.4.1.tar.gz", hash = "sha256:84dde37075b8805f3d1f392cc47e38a0e59518fb46a431cfdaf7cf1ce805f970"}, +] +pytest-cache = [ + {file = "pytest-cache-1.0.tar.gz", hash = "sha256:be7468edd4d3d83f1e844959fd6e3fd28e77a481440a7118d430130ea31b07a9"}, +] +pytest-cov = [ + {file = "pytest-cov-2.8.1.tar.gz", hash = "sha256:cc6742d8bac45070217169f5f72ceee1e0e55b0221f54bcf24845972d3a47f2b"}, + {file = "pytest_cov-2.8.1-py2.py3-none-any.whl", hash = "sha256:cdbdef4f870408ebdbfeb44e63e07eb18bb4619fae852f6e760645fa36172626"}, +] +pytest-flakes = [ + {file = "pytest-flakes-4.0.0.tar.gz", hash = "sha256:341964bf5760ebbdde9619f68a17d5632c674c3f6903ef66daa0a4f540b3d143"}, + {file = "pytest_flakes-4.0.0-py2.py3-none-any.whl", hash = "sha256:daaf319250eeefa8cb13b0ba78ffdda67926c4b6446a9e14f946b86d1ba6af23"}, +] +pytest-mypy = [ + {file = "pytest-mypy-0.6.1.tar.gz", hash = "sha256:f766b229b2760f99524f2c40c24e3288d4853334e560ab5b59a4ebffb2d4cb1d"}, + {file = "pytest_mypy-0.6.1-py3-none-any.whl", hash = "sha256:bb70bb64768a87dbbee250eee7932c84d1e8ccf68c4ce0651304b9598d072d6b"}, +] +pytest-pep8 = [ + {file = "pytest-pep8-1.0.6.tar.gz", hash = "sha256:032ef7e5fa3ac30f4458c73e05bb67b0f036a8a5cb418a534b3170f89f120318"}, +] +python-dateutil = [ + {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, + {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, +] +pytz = [ + {file = "pytz-2019.3-py2.py3-none-any.whl", hash = "sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d"}, + {file = "pytz-2019.3.tar.gz", hash = "sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be"}, +] +requests = [ + {file = "requests-2.23.0-py2.py3-none-any.whl", hash = "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee"}, + {file = "requests-2.23.0.tar.gz", hash = "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"}, +] +six = [ + {file = "six-1.14.0-py2.py3-none-any.whl", hash = "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"}, + {file = "six-1.14.0.tar.gz", hash = "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a"}, +] +snowballstemmer = [ + {file = "snowballstemmer-2.0.0-py2.py3-none-any.whl", hash = "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0"}, + {file = "snowballstemmer-2.0.0.tar.gz", hash = "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52"}, +] +sortedcontainers = [ + {file = "sortedcontainers-2.1.0-py2.py3-none-any.whl", hash = "sha256:d9e96492dd51fae31e60837736b38fe42a187b5404c16606ff7ee7cd582d4c60"}, + {file = "sortedcontainers-2.1.0.tar.gz", hash = "sha256:974e9a32f56b17c1bac2aebd9dcf197f3eb9cd30553c5852a3187ad162e1a03a"}, +] +sphinx = [ + {file = "Sphinx-3.0.0-py3-none-any.whl", hash = "sha256:b63a0c879c4ff9a4dffcb05217fa55672ce07abdeb81e33c73303a563f8d8901"}, + {file = "Sphinx-3.0.0.tar.gz", hash = "sha256:6a099e6faffdc3ceba99ca8c2d09982d43022245e409249375edf111caf79ed3"}, +] +sphinx-autodoc-typehints = [ + {file = "sphinx-autodoc-typehints-1.10.3.tar.gz", hash = "sha256:a6b3180167479aca2c4d1ed3b5cb044a70a76cccd6b38662d39288ebd9f0dff0"}, + {file = "sphinx_autodoc_typehints-1.10.3-py3-none-any.whl", hash = "sha256:27c9e6ef4f4451766ab8d08b2d8520933b97beb21c913f3df9ab2e59b56e6c6c"}, +] +sphinxcontrib-applehelp = [ + {file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"}, + {file = "sphinxcontrib_applehelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a"}, +] +sphinxcontrib-devhelp = [ + {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, + {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, +] +sphinxcontrib-htmlhelp = [ + {file = "sphinxcontrib-htmlhelp-1.0.3.tar.gz", hash = "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b"}, + {file = "sphinxcontrib_htmlhelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f"}, +] +sphinxcontrib-jsmath = [ + {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, + {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, +] +sphinxcontrib-napoleon = [ + {file = "sphinxcontrib-napoleon-0.7.tar.gz", hash = "sha256:407382beed396e9f2d7f3043fad6afda95719204a1e1a231ac865f40abcbfcf8"}, + {file = "sphinxcontrib_napoleon-0.7-py2.py3-none-any.whl", hash = "sha256:711e41a3974bdf110a484aec4c1a556799eb0b3f3b897521a018ad7e2db13fef"}, +] +sphinxcontrib-qthelp = [ + {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, + {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, +] +sphinxcontrib-serializinghtml = [ + {file = "sphinxcontrib-serializinghtml-1.1.4.tar.gz", hash = "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc"}, + {file = "sphinxcontrib_serializinghtml-1.1.4-py2.py3-none-any.whl", hash = "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a"}, +] +tatsu = [ + {file = "TatSu-4.4.0-py2.py3-none-any.whl", hash = "sha256:c9211eeee9a2d4c90f69879ec0b518b1aa0d9450249cb0dd181f5f5b18be0a92"}, + {file = "TatSu-4.4.0.zip", hash = "sha256:80713413473a009f2081148d0f494884cabaf9d6866b71f2a68a92b6442f343d"}, +] +typed-ast = [ + {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, + {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"}, + {file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"}, + {file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"}, + {file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"}, + {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"}, + {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"}, + {file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"}, + {file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"}, + {file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"}, + {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"}, + {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"}, + {file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"}, + {file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"}, + {file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"}, + {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"}, + {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"}, + {file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"}, + {file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"}, + {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"}, + {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, +] +typing-extensions = [ + {file = "typing_extensions-3.7.4.2-py2-none-any.whl", hash = "sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392"}, + {file = "typing_extensions-3.7.4.2-py3-none-any.whl", hash = "sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5"}, + {file = "typing_extensions-3.7.4.2.tar.gz", hash = "sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae"}, +] +urllib3 = [ + {file = "urllib3-1.25.8-py2.py3-none-any.whl", hash = "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc"}, + {file = "urllib3-1.25.8.tar.gz", hash = "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"}, +] +wcwidth = [ + {file = "wcwidth-0.1.9-py2.py3-none-any.whl", hash = "sha256:cafe2186b3c009a04067022ce1dcd79cb38d8d65ee4f4791b8888d6599d1bbe1"}, + {file = "wcwidth-0.1.9.tar.gz", hash = "sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1"}, +] +zipp = [ + {file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"}, + {file = "zipp-3.1.0.tar.gz", hash = "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..87fa773f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,49 @@ +[tool.poetry] +name = "ics" +version = "0.1.0" +description = "Pythonic iCalendar (RFC 5545) Parser" +authors = ["Nikita Marchant ", "Niko Fink "] +license = "Apache-2.0" +readme = "README.rst" +homepage = "https://pypi.org/project/ics/" +repository = "https://github.com/C4ptainCrunch/ics.py" +documentation = "https://icspy.readthedocs.io/en/stable/" +keywords = ["ics", "icalendar", "calendar", "event", "rfc5545"] +classifiers = [ + 'Development Status :: 4 - Beta', + 'Topic :: Software Development :: Libraries', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'Intended Audience :: Developers', + 'Topic :: Office/Business :: Scheduling', + 'License :: OSI Approved :: Apache Software License', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3 :: Only', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Typing :: Typed', +] + +[tool.poetry.dependencies] +python = "^3.7" +python-dateutil = "^2.8.1" +attrs = ">=19.2" +tatsu = ">4.2" +importlib_resources = "^1.4.0" + +[tool.poetry.dev-dependencies] +pytest = "^5.2" +sphinx = "^3.0.0" +sphinxcontrib-napoleon = "^0.7" +sphinx-autodoc-typehints = "^1.10.3" +pytest-cov = "^2.8.1" +pytest-flakes = "^4.0.0" +pytest-pep8 = "^1.0.6" +pytest-mypy = "^0.6.1" +mypy = ">=0.770" +hypothesis = "^5.8.0" + +[build-system] +requires = ["poetry>=0.12"] +build-backend = "poetry.masonry.api" diff --git a/src/ics/__init__.py b/src/ics/__init__.py new file mode 100644 index 00000000..b794fd40 --- /dev/null +++ b/src/ics/__init__.py @@ -0,0 +1 @@ +__version__ = '0.1.0' diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_ics.py b/tests/test_ics.py new file mode 100644 index 00000000..8c57dcf0 --- /dev/null +++ b/tests/test_ics.py @@ -0,0 +1,5 @@ +from ics import __version__ + + +def test_version(): + assert __version__ == '0.1.0' diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..e046dbd4 --- /dev/null +++ b/tox.ini @@ -0,0 +1,45 @@ +[tox] +isolated_build = true +envlist = py36, py37, py38, pypy + +[testenv:docs] +description = invoke sphinx-build to build the HTML docs +basepython = python3.7 +deps = sphinx >= 1.7.5, < 2 +commands = sphinx-build -d "{toxworkdir}/docs_doctree" doc "{toxworkdir}/docs_out" --color -W -bhtml {posargs} + python -c 'import pathlib; print("documentation available under file://\{0\}".format(pathlib.Path(r"{toxworkdir}") / "docs_out" / "index.html"))' + +[testenv] +whitelist_externals = poetry +commands = + poetry install -v + poetry run pytest + +[pytest] +python_files = *.py +flakes-ignore = + UnusedImport + UndefinedName + ImportStarUsed +# http://flake8.pycqa.org/en/latest/user/error-codes.html +# https://pycodestyle.readthedocs.io/en/latest/intro.html#error-codes +pep8ignore = + tests/*.py ALL + doc/_themes/flask_theme_support.py ALL + E127 + E128 + E251 + E402 + E501 + E701 + E704 + E731 + F401 + F403 +norecursedirs = venv .git .eggs .cache ics.egg-info +testpaths = doc ics tests +addopts = --flakes --pep8 --mypy --cov=ics + --doctest-glob='*.rst' --doctest-modules + --ignore doc/conf.py + --hypothesis-show-statistics + -s From 92e8a9e41edf20215f6514caee12e2d6a8b4bbf7 Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Fri, 10 Apr 2020 15:14:26 +0200 Subject: [PATCH 13/43] fix src path for pytest --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index e046dbd4..3f884397 100644 --- a/tox.ini +++ b/tox.ini @@ -37,7 +37,7 @@ pep8ignore = F401 F403 norecursedirs = venv .git .eggs .cache ics.egg-info -testpaths = doc ics tests +testpaths = doc src/ics tests addopts = --flakes --pep8 --mypy --cov=ics --doctest-glob='*.rst' --doctest-modules --ignore doc/conf.py From 5a4e681b18ad695c30c2e3464576a140138b033e Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Fri, 10 Apr 2020 15:14:46 +0200 Subject: [PATCH 14/43] add doc skeleton --- doc/_static/logo.png | Bin 0 -> 21365 bytes doc/_templates/sidebar.html | 10 ++ doc/conf.py | 297 ++++++++++++++++++++++++++++++++++++ doc/index.rst | 21 +++ 4 files changed, 328 insertions(+) create mode 100644 doc/_static/logo.png create mode 100644 doc/_templates/sidebar.html create mode 100644 doc/conf.py create mode 100644 doc/index.rst diff --git a/doc/_static/logo.png b/doc/_static/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c9c6fdc4852cc99d25961815b11f2f7aafa4cc27 GIT binary patch literal 21365 zcmZU(1CVAxlP=u0ZQHhO+s3qQPTRI^P21D9jcMC>+qnJh?tkw_{3qf>)X7Sm%&4fU zd@>`I6eQtcaA1IdfZ(O2#8iNQfZ=~$5GarziVX#N6c7-WinXYylC-EOk&?56g|)3Y z5Revht+u5a+VsNpx|Ejl3^(LBwvq_B1xo`zNt6oisme@GM*6_k`pkqnBGk}#TZ zFiaE-2~03dQN>U_=HPpt&-Lf?bl#a)S!ZXN$g`X#J&-S$iJ2K!6m}q#Gu-g_g4o*N zqAZpmM8b|BTp}92_2sS+?AJ--Bij9dsZq#iB;ZBz{wdN@ zF)fo0mlEqokGcXhDR{lHJc0#yYATL_0Kyz}B)e<4GbKnhE2D@|_8}&Xiu)lWOpl1d zyo$1Rq&7PwRuNxLo~g_#Q1uxjlLqS4-f_`Lx1w`8gtNsUG}x-x1W5Uah%hTv0i6`F zEnf`IlImfwE-hgmyy#W{A`@#kmH_4zvXJ{{Eg}yW%SZ@K_(VPyb}x5dVcuy?prb5} zKH7fLDc6ahX_pBVgBs5x?wd806RlgXNbWo#i78%%K;iiV=o*Ns5k!bE9p7g^0}_nL z+cD0`?sh!^tPmI)5*SF}fWt&S@^v=9*&5Gpv0LN>V#UWDCyMMIU?PNt)i0HRSaqPF z>>j6V(t&>7mk=m8#u1?6=*G}mWIlB!9jq(CgOJRx(@56Q%)J^%46aN;G@)Rf@WEsd zkuTV3h$nEN3#tb4@5VhsuQiV~;7i;~)JraRwDzF3Y!!dqPFAwed&%E@Nmu<`DNmEM zz;mW%Ob_9Zq|hxIqZvkzgP$%ie0TXOUuV$3UkQ`Pg52|xbE|XPrwFcyo#@+8A@gl> z@}~x_xai+2>n4l5!U6fm&1dsWR-$8s1f{extTNRB*IyelA!t<)OIZD{gs2HfpeKY( z+vpwHC@5%nq_%K71Hc0-W=Tce4N*x=FhENlJgJa@;^osG>;voHh|`MLK(*{I6X&1d z7{ELA%aFiG-Oq^fO&|>EJwSTnl*20f{^Ns6j9NmF8M_m^z`;UrQli;vfe>@x68(6# z;7|SFp27HY7k`OBe&Hh~^#dnBu=GniVjKp8D8Wq#qgO!27NENZGTb8ZK`8daa&eaR z(`7))8i5NV@eKqv5c5SKkpztq(?npH1ZI%7MxgFTzK{q*VMmH5C8&{sj*B=a%1VK& zgtL+2OChoZ#*mFNk0l+bS_o5vn24z@$d-MDWD@7%r?U z!coGn2(O^5a9W}}$Hgf)o6oUAZU)hTwTo(%^eF_LM|(7I<>A5A52=%A>(@do9G!PL z&BwwEgC~tq4t!XIIfx=QE@JXw&dku0<1Pbs!DJ1|?4L4SX@t}AuffxRw21}uLl_@4 zTCdsKQa?dF5$N%%u94#*j7BC8SP!(EMZykW{Wafb*!|_$<^yqA1rahaxv3$D4tY*_2tw3}BsRUSyGF$!0;#d}cXe=42H#{Tdk< zDVj2wMfsaFt}?zb$TA&enQF#v39#fJ)0y%PcT0kwrWmo9kT8)or#0>x($z#e><%{KG^kVLA>W=Ha>&EoDxtqCbd^o#Ld|7xs zf6aR+eldT|eR;mce0aVOKt)1pK#M@*Vsw-=Qb5+nz?H^T zJ`WBLGY^0#-woY$(3RG$98mHI@+k920T2g7zNJ5o!iT{7!o$Fiz>C90hKYwc#09UaTm484tq@NbMvwNA zb(`c*YpZS0;Ls?q$F*TLltFpPoZ{c6c?Z0d=H+DN_LdwcxihIVvJ4aqlzIm3GF>B` z?w!mXUR~<#ejc1Iv(ALiYx{|pcXP?BtV4gsVQ-F()z1NTU9V4nHp{!0dltL%-=tp2 zULG%Zw~>2`?I;%&_GfH*wG2IWWOmex_!}c$re`-8cO3G}WOyXA0G#)N2ZE>tgbC=k z0!o5Rw2nMdf~T%?at+3g?zego)>8IkA{FXZ!l%zn?+r~2v5wETSx{L}I}sKvwA?Yi z9_hPvA^M<+&WYBE2g*Fkmc_@^94QM{WE+&7)M5Dcc|NA4rijA|L)b${!=Diz(S2$C zNkR&z+w@)Ur`OMs7?J7EMo$S(t`w1E_)=L?916Y7nClm9Z7%t1y}#efo^-Dn1%*6_ zF8hnoN>gf5>MO4+=^?=pt_8Wg#_s1**+$r8HvSBDhQr1g#f9LV@m6_1e#`@0zaJs7 zWw6(=t20ZcAV*W;kMK6R2t2!QLPjE^DV-?CDf9DK_|)@;lTtI-y*9302eJ29&n$cM zw!K;@b?Liy@wPkGTn@Nfx#drWXY={`vT$?W$!5vwDe?ust-hXNzc3*farA2#kf_F} zv>4#1@jA@BDhs8WsJgH;@iaf`hH6XRn^umZRQ@f*EVQsVuX}c=zHY-zM@7keO`vKj zX`0ke=>dA*+9ztNDs>9qRNyV;CFm<+Yd@7vbf265mGwSkt+bq9psYSPG;B*b2;sPM zPH{qT#&LRaz_U5nUE9vvbvUWsS!h>F*%;tTZbrIyKK;JJZ9{SJ>%#Z)sUxj0ZE~?Z ziRLBn2)p6=chzO-a^s&azM5-)aIO0cxM_PjePk1y^PYQAIxrTk_r8ghJdd}3w0>lN zw0Yv>U-92!Dd3zFu;N-{Hse@gx8?u19dD%sRn*Uq<9qPu5ZD)F;l~l^yyd%oorwN4 zU2A+ypJM!Dc-KecP5;nop=a7{H(*=z9Q%-snOriy-Zt-`S&qY4s&aOyYQJmGo~ zGdy*GxG5+%EiqF!RqAKia zn0S698=^$lT1r z#PZk@**Mj7(YWp4_CRizXIg)HagulXIW|B(K_Ws|OGHe1NodMoOOr(BK4J;{%sW%k zS<9Dflz(Eds_EC=d2SXLz3LTKuF1AR&n$#%7@Y*($QA`(S+|AW@g;^_8e1B! zhGDH`TW>3N(^ZqrmWF-)h2s?SGW9(72>As22K_(3`cdRrRs-?P>h5Yk8jqA~x~Cp7 zx?5T@{d-2}3aA?BUkJUZ3xsbpw!|Qk$J^wXm8j<8PO<-xQIT*7FwqIq8+<&R_rLft zlXQ~8pZwR_`~#Uo8P)F#_K5*(xq}dra&bCkycS9<5AguJZzq-G6pxk0#VgC>Chpm@ zW6ylWG5$==oE^u#zr*R^u<1|1WVxH9m^v3c^y)9<=X_6EQBS1=Z@y=1>zl^{<&7rjmt&bDKGQW)~DYoFb`!F((T zQ}|qOh5;*l&R&njjBPs&0q1vTzz;#of3roY2cHx}@_fBFp6L9B+|;aG-@KNiE0~uX z>lrT$#d|fpdN}@ZD&JB#FPzd(ZgQ%+-5_*(WME`{ak)GU&TFT0=XK1-p9pyQVZ491 ze0>fK6iuz7$~p^Xq~U9xaBy%OForD^*(yJr+Qxt%+`gkz2>E~+h#%waFeB56TQ z!u^S1%phRhF}1d|GqW*qII=pp+&$kT*gZS8JFwZUM{Ot3Vo;;;RX%J!m2Nb-vyW6% z$ytzK*=y=>em+V^BX8rPy;84J!&cH)Ia!%oXkI1x6@H%J$`81FjD=reZo zvfBUD^)J?u1MQOUAX{PU22+ycJsET?2w8aVuQ739;a_9my`czvvwHpO#2Mu$v@WaJ z454;&g*Zi7uHJXK{a5~1Y0MSaDH^HUi~+jP zbx?ZCwa)crOJ*wubvCsDzE?+pOxzuuM2awrJdDU}nc59IiYr|Q?k)GL+w(2|(W5#5 ze-A^beee6r`vbosA)DaTg}nFScKPP)=J&zfV$j9!@^F(d{yZ#xbx#Vm0saYq-fie* z#y{B;P5=6y5qip{e-Uyl3Uz&-Z0vW}>+fG@;jH|G-WG(@@O~e+*qJ9f3Ew8Ir|;|l z-R*@-#Gd?6+O6u;i`MMBbWjguTr;qlK!#@aH|P<}2lVVP)W7Wexp&cQ=FI}GwyyDK z@u)diA=t;*giQM80)x(h>|yjV+u@@5$=NQ*Ga({*Tn4xT$h|io@nAAw$)Q`J>f#yV z|Drjf@sT|ekrJ@d>5@pKWu^L(D-*WK%gN&t36;jw$?OyVv6QQoysk1x_Kyil`U^QYDbYL9Ju*l!DqkzVI$f6;t^=hFr%lkh z(DG)b->^In(OB6A_+#4G)keQ=F#6Rs=q3rXCFtv3=T6*3)9&wD?Hqrb@SO2PcYnV% z)9cs&UK0O+kcaQ!uJL|Nyzz4P?nmPGVhQdE83jHAp%1G7!;EN-fDc;@rx?>f8l4ny zjT1u`9aU1Hl2(*$nq(?}0Odq_XAUjhwT;UDd`n)KEI=NA-SemZ*LMdfq{@KGHUbWz ziX1A}l%wb8*ucEU*(}QjDVWY z!5S*C3B$|tdk_%Z6t}icTB$>I$4CSvKI>_&Blj0!ij6CQ05v>*0_7Uq;1^nho_%cwD%8FxM1QjVX6#zU*JD zQ>7?Y_KPT1Jyx{VtHx<+qO%fx+5W*svQ`!e=IubNxpEnsctPt^P8bMjAR6E+8XUZ> ztCwg9c#;g|B$kxvysLN~Fo=kX`1PnaEQZj1px;?SsRi*!$x7vpIT1Ny?N+U898Ni& z*IQtN{eEIMaHy#T(K* zXxmUdNS{c*l?H`Kc}>|-4OETZ5Uvw&=@_&L+BR(Pd)T)qrrgu@_gh`xWf7+1L>2P& zvGmZl@U{>5VfcM|G`i7!Kim(meMQV3TqurS`ry#uVPNWDwBZ(^6=P4ghw-toma*h9 zMPquf858D`PLsuwx4Iec1NA1R{x-2KAb%B$Vu#0D=PC(^3w$zd@>sImoQh`-bebei zc1_~Y{L?e9Z>{Dx5cD>cHPwJ!$Z~~UO!UKCK62>;6y6pOei}l8h8yy43cNYf?JlQB zx;S`rYi4BTMSm>`eD6+n)KBb8q;vRFeYqOA>ow}D>kZb#*5q$BZ21NdH5>~8;R%7+ z0*Cb@YltKXVZ``TVu1=GA&9{{3GXN1m7sh5E)u6MZY^d};#4M_AF$$drFbKN2vTq4 z&$!6ot|3c{=n3?TI~eIWaCV%%1Mw8}1@{kh^5xUVV}J z!23W1Qvq`W?}Ob&ibJ1A(#Gf^ch}ky4i+_$Yf_6Mv7;NLc~EGTzs~V(4o)sQ$Ec&X zkx8DoqIsgnIR@41KEdlZVt+fi~Jcmvo5 z1U|wwoc8Z|g$OGWMoAnA{6e_Vh|!Tz0AzCp%*REBZ=;eUK9vsOBdJ z8N|5gHIWOIg4TAVgn9wR9LVX_DeNh93>pQB3##mIx6w4yRb%r~Y{m$|sBdXhg$Ibb z8)6>bP6BVzIv~& zA0Qwad$b0UuIqwcG7BsXHtATU&BfxN6kdNRhhe#r)H*dugXj2 zq7Xqs(~!~NJL_+7Vx?2wxCblegM=(WHUEk!hj(j-yWrI}ejYbC4`WxJbNZvzJB&N^x;UFY-o*C2IP zWpele;w1i=!cOMhk=6wCb+VgwYq{kN0XJppVlHk*e2B@T?st4G0-kHg{z{=p`u5JBp@x6cQ=+$ox z(cg+&d2uoAu}^(WxLsc|hk(Mn4o-t)QdQm^dBtSQGRxfKUjj1$7MsBq91Q7FQd75-oWp zB2fotb0T&IHU=gV0T?18B0gs`3mz3QiT@G*`Qj(Ba&>j&VPy31@L=#@WpHq|WMt;% z=4NDKVPs*U|KXr_@v?U{_N2FWA^mS9|DPT)a~D%*Ye!dW2YaIb=ruNRaC7A+A^DG? z|NH#+f0}z*|36FiF8|}!kAsZ=nPFsRU}F5gx_?CZ{zK(avi3B$)e^I|Gq-p7u_3_1 z!Oizy{Qtk1|7Y=kNoxNuNp`OPE&0D@{zsCJ@jnjyFNgj+TK`4;36}s2ALIWGy#Nf^ z&@K)T5I4KDn6R2B@Rc8Qiso>t$H$0WQD7b_7_zWLr!r9!Tf1BdE|PpoDJ~`$aTC=K z&_rj?h4t4?tXKq5W+@`Dh)B6mSP$8niPb3x0=);c2ok%%rm

5@g=XI{XzrSmk5*(krIWJME)~qMs_jWZm zFU`}jwzk&RUT-iSe6-^J4iX#h0*2|Xm?0Qf zAw6#QbiSbB?|2+#v!p;k>EGFHO~DEM6_-#KHVV&9J)b0jqVPOEFBUeM#42Is`3db2 zCVMdF;Q6rWU{|9wJ+ZM_ozfH82!4jusv$|M=w8fcHC}~G=7tQkX6r?YIv8qfEcp!1 z(ecM2J&i(UW|jsDjMcoy_=Y)=bj$Q0bM($%3Nfk;VkuiWn^qZ_g!K+}8V-~SIa<29 z)g-`eU=pyMcFn9ayTWPr_xD_uWK8M=5i$=M)=iAb1OmePLx|mv7}12V`bR1-xw@Cl15&)=Z@nr9{csuCe6_Yj9M8s~Wr# zIc9CNz`{pf_px-jLi#1+PJ}*kM8_KMLa-?H;$?Nzy)B5_B|z;5!vcTAT40aVC9Q(> zlo*0_OmfWJctJv;Y)3Q6(;q`FO4bnv%Zx>K2S=&JTIrgx6kyJqT#w%GWZe=IbK>v?RkP{(re5}-E1h)zF$iId4yQL&xt4o6_xkS z+e&8q#?|=&-bqidp7wQ^@`SW)>e)d+qZ-6@Uc|5#S&i%8Z*_}s;Lwle?9bQEu)JLJn7_#d-_P}I;B6d!%FeEpp+IuQBy zxW;deXVh0dn3ug-%qP(5D72V|g@+_7V6tjXs|y52PD64fOfV>s6iTlEVt5>lsuR%F z*oh4&QuTQS@@i^o4S>H!CTp9(v8V zLdMoe?e$iJwPk3EX6)oRz5Bro31Ouhfgn6wu5qs|3)*H zm`hJ!T~u0Q8jHM)!!lqmg|;Y^El`O;M#J+Woc7ipLy`q2)Y0MLh07s~n}UO_uP<6MV&_Huu_0xiw2wt0=cTi{7NFbRr$CaFCh(LW#lo zF-BZr8l@ZHqG3U7ss4EBuS?O%kENx;WwDSAk~msFa)AYp(AZ7m33Lm80GY||IWBzv z_%|OwqiG@@xR#n%Kw6>~c>v^xPV+Zb`N@T)qzwXb?w1JO0mLM?0l16+{PcG34jh0u zFh_!a55mvU+S;QsB-l%i4U=*@%Qdrvh_Xij>wx5dA`y;+rwZ{9xkQe~KQ(0mLNdUZ z1g6clTv>5zMM-gbJmDv??%6LiFSgmm;k3pxquONyj;|>?ug%G>Y z{w|4+Sbvl$&MV+N0t;EOU4Ln3$Y0R>OC2m7Op5Y^TGX3UR!E(Za)BGprl$jQzTB$M z`}Xp$>&E}}D19;CH%BDFYP++b#77Y!S-jvZK;Z54>1s1+ltNyC>pp_?l$LA*4TGUZ z>-Oi=Xc9`IppBnPUGs1xaY84H^)A|9hQvFD!7*P;n|2yFgMP+{R{|~i084(12rU0g z34y!%{eBwF&@Hw2YZwRE*-6{*!x8`{(^S)RUt&Qib0-QM&$WLm{5xC<0OpEhOXPty zTt@S=tL;Of%A5+VC^4;~E=;U{hp=Fh4lHSDJ>l&eau^v6hQ;!DTyH%N{l02Z9YqKy z{89}03IhGE(CM+*>2*l&?g+8l;rD**zQsI|+%>*9*<8~@6yRHrOhU@~4Pn4>Iq{dy zj{#nid@-4aWp65p=VppP%q9Q$U_b^8p@$?isSxO!+q_`iRMt@gi5hd|BT)va=&t(f&qkp=XjRf7jW74{rFFHG2aATVrW%V;L8(V9LT5C&u3Bc9gMEA>!#y| zvu&r0B$P2&U~+*-6ZvGX%5D*QpaXw%7t5uy@a3<)C!S3psEkn5$ICCuw-|=}lz`>s z)!zGaT-{dxxNo!yldIuZSW)P|NM4^|@B!aXx}s(Xc1s!ARBiDJJ~=)%-!Jo{Hq?IB zAcU*EXs9q9#vW(C^M}-3orqcG0zYKK=!&no1ieHN{n2XgAQ} zi3SS?jsw%^GoA}^*F z{nyQtv$S;iqY96S&}?ua=H|;&jB;+Mu?$pLOOD<~q)`c)u4rWm>|7WW|Mu{7K}|jl zs$MFYJgB%#KD(cdL@c69PL%)F+~#?SBsfIkBizeH9UR%^qJlJDjTp)EDvw`3s?&C@ z!9dLCq1L>|JB*i4gKQrLq&T`7_9OYi-z}E*EoElZX7#iAvrrlrh7vjlI?8<*xQFiz zi&i|#v4;D4FUz!?SclI81fQTZ+pXgg%hA}Im{-4df;hxG1QFgc>mzb1Jos|`4<^P~ z&dE0r`)~XH*DzL`39Vp(h^gOdR2;sfE)tkn5`KPYFkVp$1xPbE&wqgmv6BfI3ATY& z@ldN@=6OV29=~{o#XxX9ZcT|zba13<^;*>~Ynk_i!eJ8?sHA>FjDB6LTkaz^t}0Th#z9TlW;IUD`;=epo8bBQ!Z}Eo&H4Rqe7qvIvR;=FtaY@`V$5ul*4yAxzY8U ztzNxqwZHmyIkRJ;5y(@0t3Fos>!ft3gwkx5V9I5|e*@adha{EuYpu7X>!S%`8tlq5LfWU4%${f?h6R8M`&WLOi7lt%4RSG^f6?)a}C-CG0}H5~n0L`XUI zo7N39%1s0b`^RM2v zsPuU%BzshMp2}>4gknF9M}%>RY!9F`xvljJ+NtC=9|p5pVYemu8!ZCtuNg60tUR*) zLEh;mg_J6vnQp z!}u~hZe>Qe7X;^VN5`xOebUr-MU@5O2?M*(=AUxvLZc!%8a$^oeLA3v`didJx}jSA zpyiOTL8v3(i$QfaSMZ(Q;xO2Y`2|o^Eff&aX>>{zo>OhLL2x+DU#gLdwXep?DJcnZbk8EAwyhj-9Slr4GMIi_0GIC^i&otiS%N#E5==Si#rC9F#^BnX zVd<(7aEC`qiip*e>53+rD<(g+(Jch!5$yu;uQ)oqOjsCJPqcE-Wfi**|JTKj0gpvAtH;>ILW{lM|NO^xaQ2gxFH?oG2V_?BgCo2sjROK|$1MiP#9s|&B z$MYNx>pB`M`6N=PrvlMKrQ(<3;AtaNS&e!7GNo)j?thL21L}kE29-zIUZc&}P^IA2bsS z70RZ-*vuwiL2UM0o(rIedxGKx(3_y`Pt~_GyD5INAF7 zGj{xYWg?YBiAwU%M6e2T#?00{I)v0X9D2H(Xw2rI^1|QKQ#%7mC1xF9DQ3^tzj2T5 z^8Ekk&HkQRV!r~Jw>sexWZVxWvzCzTkDxk2z18_& z7K}zk%XZNyl$fexr{?vW{-w1i6JD=!qit>_3GjyMWXnpYSo;fhC3GYKim)liHib9x zi_QCfe#5Qg*H5DwH;P#ZvJE^}yE$_r!~=;16%@#RN?B|`gb4RHn3rY0()~@V zSR7&XM8!%W(#kMV?!8XuiYxIu~{Y9I!@=0Nt5VO$t!s&YxY5(@GL%e0!_BtJ`|uxbvrNGfSCSxfDX5>#Sql?JDZ*9R@?r&3s%A!;St z+-2lgeGAQZIXCOZItOM2ZsSq8X0b;i`__P%t(kuu)`hI8FY1eOIAA35iJD^=C73!a z9*s1L>Zq_Ip)f~<0XGkooDrcUt3F_Sh@)=$Npn^%(fKm<*h8;A2pF* z2!Ol5b@Udgt?d8x2dddMtD(&j%8!{rZp*dR^?^;qI7Kwc5k)mPepILYSOC!ir83Sb z#+t%->SEHQh^`Lmj5K@LZbL({gdsdk+!{c|WIGle-Ix(zI#IYJ=u}Ec6$-mV1SS`a zkbSz=h>DIT-LBIcX&e&kE@Vd}omvldG`d8l$(Y7`A|nDej6p14OdW<^$?d=8O@be= z_AC1jf?VBT++1&|cd!I%6g)hONQLbc$pthVYZ`)danG+P1VEH3x(K0jpwzmcMy{Cu z?aF8tgbH${+vt75FaE?l>GD^RAX!TF?Tjdj)kb1Zm=mo~x`DlghNd!WWLB0Cb_JxH zC<)7<2vL{B5NS#Y%*mLE#R8Uj$^r#@P1c5P;%PstL~J&ChUh`b%{#HqCfUJ!wES9D zU&Ei3g1&0}8_k>DMU%iVoj$h&7!5WGE(o;$DHIAU9H3J25QFS%pG?!gzkrCN;v{WJ zARom#YAm`&f*2U_fOI?r%53+{D#JapBCZaFRW5yscrPM{v8p9AE)&$A1~i=&n?NGu zCH<|!Hcc7uEjAbiU(x}EWC`{Mn>~Z0-Z6CJa$WH^$U04g&}}JZ`oQ|>Vw>SbaPYu@ zNSddc8-NosAmIIRHr!;9v2SYjVh}PxqFGyIenB+A3!I_J8_>d;m&>o)*_N0z9Jo^7 zTodrUfVzCbr)2gH)`OuaF0*{7_OiDdlvN_h)bTs0v>-nV>;n1C`HT!xN?2X0B*Q#V zUKSG7%oQEeVsfB@grO1oxw3ck=^V5HjuieGF%bm}y+aUan<4vTLY@B{l1EHc9s6pp z)jgeUsrp2#r8k-_%gUl{3BI%z={|Jlek7@M$?dONkBoG}q}^ryr_>rxFpSE`07Zed zfge!XGFlmcA*y#-TZ0mqKQSz69pkq>ok%U+w$g+Un>hS%gFu}OcOmqt7yl{}iGh7J zTq>e;iH>54drICxe*$J0KxsNoJZ?A4dH)G>-RxBiXB#*eLrWkcQNEd~pe}F_q&X2n z(}aL8D!3>gks+`TW+RSrH-<$9)tet0&g(BuYmMU?Lbfx3yKo?RzV=fC3X&%IqQeRM zKl2uZq$VsHWY3R_&KBUdZU~I<3&}FR58d;yfx6VEbv*nS@wRxvQQ$wLYSOy{umOos zf(ZKq8778*EZ`@5j zlg&js9tkN9)YT#4DSm8oiHgB}j-!z#uB3H^QW$v%YrV}eA)V3}cF?ZJJoqLBg7QP- z5sG<%6M#g^x(@4MlO+dAfM|gidD*;91m%hC63waUb~u}ZHEE}HrvowIsx31k0>~Ws z?sP0u3s^nh$dR5T6g1hLnermsHH~P0jGAcWoyr7Lm$O<-$&*Ux#eP$tupV7$mVED1;TH3!&2v4nDT8 z>*j*tokqrW@9P%?1p8KN9C>}0C&bD!b*)d5Z;M$sW{aAdl40`;0~}@AdXuajkM>B7 zM^ryHAJ@}59t_&O5Bu;(K5n6me)60*@8MVXk(e_l3`|9Bcw7o(9E2_-)u>rJYG&@& z;7Jm6r=09#w$0X(ZtFlT_cMp`bhR%PA`daxxbftoBRr*t(nr{3yI<$#d`?HocK@2h z7xAtPueRT(;w2^$gINYZL)AmD%I_rdFY=5 z<=RendeDXDRv3Q0dk2E8>y--0QeE5R=hC|HEt+ zF6)wtXQ?-`*U+Tqxyyh#cmreEaPv^QwT#$QQ^Td6rO4&j`}^=9q_lQ!f9dDE<1u?C z(imYA3@qg>jNyK%)<+@IMwutbf|?qYUpPLXz}(;A6yRq#SdGP259lNN5V>2NPB$0$J_kjyn_&A#)b`#Xo2HRid z<33}(OGMzk!5WSpDG=w-R!!_`rSq2{va6(0xC}V7aTaE*`wi%~4er~NKm+WMAOvUH z1}{uFksbZAX)5EYzjP{YFEKYH`cYp3%~l?o`!!O{|^0RX_N@gVen5T7FJW-$(7 zYRG#+c;S8K6y zQl>Ti%iwrO@s3B4EO;ItPN&sK{dP;YMz9edw6quH6VZ+!8Y)%)HU?J%bSx?I$kz&j zu8UwXRI^4xQRzPJc0S|yRA}h^G%Cx@qbfaoxW%2J$P(boB>R5Tg5(8)F$qAHgI=!fHEUc6{A~i*FMh;XkfC@7F{(AMY%+_?2?0>@KvO)A{^0 z3eGMn$;b=L<)zJ@q`a`5RnoYl>&Z+daw*hB2Z!&j`jz^kq4#o7Rg63-1Cvy;UAmAg z%ZT3}MS36g?4dztc@~51qpIbRGfhCeU8PQ|Ubku(pbnTo>GkV!1{W(I&B(gRFz@*$3*rjB(!UyjQs|6NofuHGFRXc6Bcs}OktTQ#J%LLFUX(o-#7LX~F- zuOI7V3cLHk*%vIw7)CKy-qqPhb7|IX1=q;Pjd5!x9fT4d%E6BO=X$h&{=2T04WhkCpQ0 zviCY2K-3YvY0Zt$z?pMZ**t|SDSVK((@N-}A5ID~YeQCM3Z8A0#*yoHQ9gPO=of+D z!et9No)6xX&$^O#3GsM)R2;w4j|PbikVK?gg_jyd&Tc>2Ny|^!_k~K7U_(i(D6&oc zMZR1-nd8%6i;Z1doSe>?Yx?)Om~X}NjbfmAwr}r)8u^Ogp1b;oa;D|g_W}c*72$-; z3c2?&1OthCzEPDj%R_POPVA3O=?|1nwf-Sn^M0rrkotCvTWl3|V}z-}3fK9;!XR}} zzY^k%0|=puGtx&}-YrOqvuN!qY0sw7B~l%IqPxeSz{K zucXZK#H88ky*g8SlXB#YZ>k@9+!seDK}z@!G`{BrdbmP8Vpv~BnRnVXx4-WK5YHG& zctjg&lRl3egAbxJ_+XK4`0ZJHhXyl{zW-%_6cex=WlW?_9uDPbpKAgJlSQ{BVMuKO zSEj%B{QmBg$hU4r)08OdZQ??9Sf?+DC)tm%e_S1=UM^*VTq2-EE3<`yV(h)tBv!vJ zPcoBMmJ9v;65*&^_rA<7E-tR};rpknB$Z4Eh13%A&hNc4XAKBRpBx)ig z!oI11LZ?Dv8tx#!Ff&@vxhpNhh*qr*#-V8yvvF7TPpSHmwkgtXhw<1H(&>R3?JN}O z4w1svt;Cw}qy@W9HRZk-GHdF3yMdC%wM9)BPcL@8e?Uiz1Vi zfX}ml)+@9~RjYtc8{aht5}l2=St-Zd(xJ)fXw5VZnmm7O3=~q2=f28#tWHON&$ntm zGcO{$&UV1XwNPfwK#6Y6Tz))MJaQ^bqwcV!h+I?7ov;j(f4boQct zgp9ZgwS83lomUpx0`~TIh`> znHS;M`aUhbK5Oa9SxuvRGb+b{Pp|v<2!^F%g~qd0nEVnTev>`)y9iJwU{+2K{6jhU zMRI#?v?2nrsFVcjom-AbzqTsx##;DLIWlwkH>kBL$8lK5psmBfMG{8%Ec(UjH(av4PMX!NwVN}iZ`FdO2lPyA0 z@SmB2S_?S2j=3|r<}L!v33_7=Fk5C!`R2aUREOu0mQk$HYHZh$1em_#Rq@`nc@Ao9 z-~Rr-(Ebw?0bnSJyNNbJf8Si1iGLl`Ly60>MYTMRgAK4r27UD1eL|pTvQZ+vxF;H( zQPZz^rz&t%_=sv!=Ic}UEA10pmzsXTD$D$%ruJ; z(#SeGTXzw0s{Y~66_I}T9Ef7M589$j%vjtvc0X~C9?Y|-Uat-5LyPpAEZeK^iHGefxUAn`KO)5 zUDvma@+D+4ZMowgI=|tRYW`|Ww~TEQmQWgt;-~8J_BHg zJ{noWy~Dge4=3%P<5T}Gu|J~3012)8g~P!}EM`x4dr3(K9DBysAWe8xYW2hL>+=>5`|0J_^zNMzU9&g=;p$1$U zBS7w1>#gp&0qwoiV|oX_>VN!XZK)atEtSzEB{a-_Z*Xz>SPZ>38%xuTrQ+iHGhD@> zQykga?p(5dZ%>2Tx|omW#8{*dt+5O&<>BzoZkD5vsL^yvsE3BEZh8fL)zWP4_S`wx0`SXJ}g%Yyx zDNP6_i^H9YL^#KZN-=Qt!b=tZ{ipZ1f_Cq&9X0w!?z@C~1VQ#rSuQVJf3C#%A32uT zM+7#U@Z)m-i5RyvF%UKW30vWuNY8raIq`f~TxS!{nl5+<;;{^v&YLp%LFf-$kx(*( zZeVC4Sv1k%)Xvxt&RtE(C$A^wA1}^x{LQ(dm~h|kxoaYQq=|t8lRS`;umSykELbpX*f8lLNLtTaNo?4-k(*c~oX6wAn@b617}Q|M6@#!b zi=O<1S$xfk66)(qOJVQckRrqYgjtK@+J;>@*k4&m>IpoPnKNhh?{8!`$A9KkD4|c9 z^ji@ho!mKR`&}R`7HR^4whaBHgH{D2Er#WcCN;Xatluyho{5Jn%g4i9`X@YXgJ&p- z>bwkt+-X61e`S>z3kGv3yzI`c$Vy7w>}9v-3VWVm;)-x$x5{a3tyge-#tehkz{C2CF0r7&XK-K!`z~jF^9nP zIPtVn%}eXuIR;pdtPJ9&kv_kx1Ls?{Gp@fg3)1oRQY zPS4z;eSOK@%OagrIq@QUZXv1GlUv69N7tfGZY%7B+Q^aDV(R_A`|cAb;6Z=fxSO4n zk`f~H?q!ajR5|g4H>tbpWa)}uJ<+=#f#b;olm2tx{{7tNN&FFu9mxR^+%XY^F}PCs zG?-|lox*6IbZ2;AGt<)f!% ziZ3bW(qmesn0WwnsV8Aq$ZHOoc=N4gs?w>33#J;83rOD`@(aa*@- zb6#jPg6PYnuA`H9mtVU}e_a-$-J1FMbANWus4+K16Pfc|zkBpi4NW*yCEb3eLCD=# z4|!&LZeQWc=^>6QNAn$TBHei;TvGN3X7jq8XmlKMKI;(?z``# z={!qF4+Mh~pF+1pyt>TcxA1Fs>Bp160rBXgj&fMlXr%VR z&}2{fE9p-9bY9`KuwsEfVfxh*{gMGE0Xu)9A(V0If@Uzm0@ZY0#N$dVy?gf&NwqGJ zAKD;d#s^)s0vpkdDJgyk!Fb*i1^9qe;=hO6c*QMg{kXVH4Hm> z1xqYFzPpnenGo@-C*S}l4@p%b&^a;T-Yd?KAy;aK^1QdWh-}4^>IV%P>{aaPB<@#F zN=r-OqDa?$X0>R>7~HkQKyIJ&_JF zgz3c3eT0%o53Qpz#X+6*!Qzt6wd1xeDu-N2*c&f?^+Y2_m7XL%w-~4-I45f8FR##4 zEpJ|li@R_-eM%0ti&@Bi^(2BG7Z(@DECRN;KqbLCu4kT^#b(LlG%iKqYL{B0%ioH6 zA_2d862UI?U19FS(?>+N23$T!Z*Bp3(c;BvaGxmKUK7|Q$I?fnD}|TK5e}s*>RIM3 z(|*^jXqKg@`1W04X}LIFXw<0DddD3Zv3tzMEy01-yK8IzvgxzG{`D)uMq$CJEnBom z;Rag5-kdqlvf{#3b?jrkO8eE5oH#oervqOx@N(%=Q?@B!uOfDjv5>$t6yu|{>sD8- zUbSkKX736@%!39G!Ls~RtvJT*W!!~zg9Z)=ta830_|+55`mP8jnU8OPLm^D=1Hg*l z+sBVHVSwny(zlkrvtqes`h_J0H2h)P{d04lPtW->?|)FoIl-mA^imX-`FfI0Oi$LV zS(B`Y;gH;M$DQ|2y4Qpaj0VRxXfR;FWj}4#mNS!f?fPQZ7hhl>KRm0&iz))gB~@|A zcxIQ*Fzv#QS}KB;@C_R_uoN!p(~o_uZ)v}Jq6+>dE@eZ9gyj?a8TDHlS>(v+^Uv#! zqy5q_s(kwALo24z?7>GCb*PLU<6T3C4xKV(N+@pPL54$8+^t)n*B%cuVkz&u^G6XF zKZISef{V7}XCPH2dejq4!>7avVI^L%Xfe#2$kq`{ilpJXm>>TPOkBq<5+M*w&S0j0 z%&0gnn^*Bb;p~bP+(u8~^)uno?kX=Y$2_C`(tn2^CU6=tLRr`S`ns>a`r5>0$5D}Y z-8BJ0ty^!t5pghbpbRMyhXLTkjFX$66oCOxS+Hr5Y-Yq9e0}!diFiHg2?`KThz$Iw zaF+vdq9OQD5^z#*>c2GTcX?vub7B8yz zg9$!O>h#3Zx)p~?0^14q2jCt&T=8|vDJ@C(3;!=J>&vzE_uhRM5Ey0;J@i1%9qn~d z@80*^eJ4El3wrhB>QxUP{pyMI{P6gtY_B935PJ75mBxjo)xoa%`yY7l#TVwKBzPrd zQV+yQ5u--mcw)0=dbE1-)~}vKu)CbgeRBCDfvdJOg<>=qan*<}MV-ti%eQUcF=ouz z-hKM6UANXemxhO=UfntIj--lCTe*JqM7r}N9ueRwpJkORP?mm!yNVeMAY;bfGzU9W}FOKb_MWLc~%(m(WX_gkL?878wr^?Ypd>w&$pRO{J{yqWegXW_2ZT{2GY+E(k6k= zw{G#uQZjm?snW+EuX8n|XiC8@d<1zj6H6na+$tq*_lj&XdMJaVt~AtlGD0i$AwyGivos z0)E#EtE8#XlXYv?s&ZxUOac)k5H0S?735J*xRTgM&G`}<6=Er5=}ZDt0?vm-uRXNmlmfFvCV^-Y zu%C=axi0m@@bt$(J()7)zoJPb%T+%maNxi{G!@qz`lHFky>h819BJtzj4sXHr6kM1 zL4%}uGhikGsRXK4>(1^;-FPCc0|(UHuthMjK|Cb~^KhJJk2xxV)cGeSa?~4LSV)3n z5l!f1IGF^f1T=FA9XfQNXf}xi>@Lk=szZm3h`gHJMhaQpoR)xQF2TuQ0^!81C-C(( z*OwL*73K5?%a~6@0-8mTIiM%vq8jM^@ZrNXqQu!enzz}jOaf8~Xcj>_33j1SkK>7` zCle=5l-831k89M3rn5vQ0TT(}d}kHfgb5SUIG*Sy+EJNVvjT3S%JzY%K6wULNdSkS zt6q&BJzDiDEZpOGBC^u5WlIs5K{5&0NB}nlsIohmIU&Sj-G2fn9@oiNR8*)cmcjE_ z0@!3qYKQ4_)y&|&ecvv5a|zITo!{%oBaafLfg8w3yc8xnu|DFts4!MNk?J=#R1pa_ zX>x)nmkg3y62PZKJQIC<&5j+oMGVYGTi9xt3y0%u+*s$H7Ah_$%jqdKk9s2d@rZYE zg9(l}0V^?V^raQf5^f~`H+to&)hk!7{P4pyuCB_!5#WT$h7B7Pv?)aTn{WO`?_*iK z_+@$_o*3zxKYu=K7R}s4wCcyIC*ov0hSz9(XStG+04~=1@~>aL``&vnkSdogwY(t% zD$(Zjw#OcGOy{D`u%p_yJ>9TgZ!B5*#AA=Y_~JYkf7GZ^Wo2bp8-tDP@7{fix^^un zC^)T6VT9`p7LBEm32Cmv|%6{fzE(6BE(fwLuUM*-8^fQUNKqnH{bxC=?N81{0XWRn>KBN&uwo+aaAZP>aum~R_aS|Z}HftPrt^E z8;fv2aO+((m5@@Sz!@`UVA!FLAri2TT>aQmp(i3)aRuZ@AFmbBGDu_ztg2c=^5Whi z{Bd+JRzTo$p8o^FCs>-zey{qIhNI7k#pKbMAJ17?2zDX5#f7}!T5(hI*t+=S?;oo_ zAIZL^WDp>9U?7p>&M=4Y&ECBLVod1N`QTbogr1-Yixw|Fuzx@HQQNx~_ME4L0<5nH zF1|Cn3szQ-9z8f&kSl-TmOy97t5&UAaX6Ysa&wgmK{XXc?mATE!YoZ1mhK~Jw7a&J zWs*!gPzd%^P@(PFQ|l_^O6k|b1iTc|aU<@EHW^9+e)R;bz}A9A2o1&$%mr@x4aL1D z-Qz#?)agoLK*(F0x_j3X8|JdSc_smPtq9%1L}!m2 z!LUh3PFi1d{G{)v5G2?ZFGXOH-KL%SCt_9IGMZ6Ki3DIE&_0{6gijx6CZNlQV?bj~ zFfLM39YZ#p=?U9I7?z!(<)Q>I)lHi_$3NNt3FpuegBU;TIKXAel}BfKBF>grih%34 zAo?|HbjQzF7UwlP^&n{})Ow~6LqmdEfK92yy1u55s?0Jf4!KNPJdKVMag*jq+okFoKn;LNn=BH6M^S1>;gx6vs_-*- z{z<^ic)~7lgmsR78m)@9(Lkw5duZembu5rnhLB0XhXfpqCvaH%KaEI~>tnX!>cz)}JZ^n`MddAd{-St8FQ z;6P8Pzn&s+rJCl1Rfn1b;8$HRznrA#`5-^v51OLRlunaSkK#ocv(~}(ia2eaNN+8pdSjC+c zmZK8L^dv_=T*h{+63Fx

+

Ics.py is made by C4 and others and is under the Apache 2 licence.
+Contributions are welcome !

+ +

Useful Links

+ diff --git a/doc/conf.py b/doc/conf.py new file mode 100644 index 00000000..e625b116 --- /dev/null +++ b/doc/conf.py @@ -0,0 +1,297 @@ +import os +import sys + +sys.path.insert(0, os.path.abspath('..')) +from ics import __version__ + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +needs_sphinx = '1.3' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.coverage', + 'sphinx.ext.mathjax', + 'sphinx.ext.viewcode', + 'sphinxcontrib.napoleon', + 'sphinx.ext.autodoc', + 'sphinx_autodoc_typehints', +] + +autodoc_member_order = 'groupwise' +autoclass_content = 'class' + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'ics.py' +copyright = u' see AUTHORS.rst' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = __version__ +# The full version, including alpha/beta/rc tags. +release = __version__ + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build', ] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +#keep_warnings = False + +rst_prolog = """ +.. |minpyver| replace:: 3.6 +""" + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +#html_theme = 'default' +html_theme = 'alabaster' + +html_theme_options = { + 'logo': 'logo.png', + 'github_user': 'C4ptainCrunch', + 'github_repo': 'ics.py', + 'description': 'ics.py is an elegant and simple iCalendar library for Python, built for human beings.', + 'logo_name': True, + 'github_type': 'star', +} + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] +html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +#html_extra_path = [] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +# html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +html_sidebars = { + # '**': ['sidebarlogo.html', 'searchbox.html'] + '**': [ + 'about.html', + 'navigation.html', + 'relations.html', + 'searchbox.html', + 'sidebar.html', + ] +} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +html_show_copyright = False + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = 'icspydoc' + + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + #'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + ( + 'index', + 'icspy.tex', + u'ics.py Documentation', + u'Nikita Marchant', + 'manual' + ), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ( + 'index', + 'icspy', + u'ics.py Documentation', + [u'Nikita Marchant'], + 1 + ) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ( + 'index', + 'icspy', + u'ics.py Documentation', + u'Nikita Marchant', + 'icspy', + 'One line description of project.', + 'Miscellaneous' + ), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +#texinfo_no_detailmenu = False diff --git a/doc/index.rst b/doc/index.rst new file mode 100644 index 00000000..895c65c6 --- /dev/null +++ b/doc/index.rst @@ -0,0 +1,21 @@ +Ics.py : Calendar for Humans +============================ + +Release |version|. + +Ics.py is a pythonic iCalendar library which follows the +`RFC5545 `_ specification. +Its goals are to read and write ics data in a developer-friendly way. + +It is written in Python 3 (version 3.6 and above) and is licensed under +:ref:`Apache2 `. + +If you do not want to deal with the complex iCalendar specification, +then ics.py is for you! + + +Indices and Tables +================== + +* :ref:`genindex` +* :ref:`search` From f608e0c5266a6dc2cece623a1d16e464472807a8 Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Fri, 10 Apr 2020 15:38:29 +0200 Subject: [PATCH 15/43] implement handling of attachments --- doc/event-cmp.rst | 3 ++- ics/alarm.py | 6 +++--- ics/converter/base.py | 12 ++++++++++-- ics/event.py | 5 +++-- ics/icalendar.py | 10 +++++----- 5 files changed, 23 insertions(+), 13 deletions(-) diff --git a/doc/event-cmp.rst b/doc/event-cmp.rst index 7c2fca70..111a759a 100644 --- a/doc/event-cmp.rst +++ b/doc/event-cmp.rst @@ -66,7 +66,7 @@ attributes. >>> e = ics.Event() >>> e # doctest: +ELLIPSIS - Event(extra=Container('VEVENT', []), extra_params={}, _timespan=EventTimespan(begin_time=None, end_time=None, duration=None, precision='second'), summary=None, uid='...@....org', description=None, location=None, url=None, status=None, created=None, last_modified=None, dtstamp=datetime.datetime(2020, ..., tzinfo=tzutc()), alarms=[], classification=None, transparent=None, organizer=None, geo=None, attendees=[], categories=[]) + Event(extra=Container('VEVENT', []), extra_params={}, _timespan=EventTimespan(begin_time=None, end_time=None, duration=None, precision='second'), summary=None, uid='...@....org', description=None, location=None, url=None, status=None, created=None, last_modified=None, dtstamp=datetime.datetime(2020, ..., tzinfo=tzutc()), alarms=[], attach=[], classification=None, transparent=None, organizer=None, geo=None, attendees=[], categories=[]) >>> str(e) '' >>> e.serialize() # doctest: +ELLIPSIS @@ -78,6 +78,7 @@ attributes. 'end_time': None, 'precision': 'second'}, 'alarms': [], + 'attach': [], 'attendees': [], 'categories': [], 'classification': None, diff --git a/ics/alarm.py b/ics/alarm.py index 745a2373..79589413 100644 --- a/ics/alarm.py +++ b/ics/alarm.py @@ -1,6 +1,6 @@ from abc import ABCMeta, abstractmethod from datetime import datetime, timedelta -from typing import List, Optional, Union +from typing import List, Union import attr from attr.converters import optional as c_optional @@ -10,7 +10,7 @@ from ics.component import Component from ics.converter.component import ComponentMeta from ics.converter.special import AlarmConverter -from ics.grammar import ContentLine +from ics.types import URL from ics.utils import call_validate_on_inst, check_is_instance, ensure_timedelta __all__ = ["BaseAlarm", "AudioAlarm", "CustomAlarm", "DisplayAlarm", "EmailAlarm", "NoneAlarm"] @@ -55,7 +55,7 @@ class AudioAlarm(BaseAlarm): A calendar event VALARM with AUDIO option. """ - sound: Optional[ContentLine] = attr.ib(default=None, validator=v_optional(instance_of(ContentLine))) + attach: Union[URL, bytes] = attr.ib(default="") @property def action(self): diff --git a/ics/converter/base.py b/ics/converter/base.py index 2807f8a9..cd9cf90e 100644 --- a/ics/converter/base.py +++ b/ics/converter/base.py @@ -11,6 +11,8 @@ from ics.component import Component from ics.converter.component import InflatedComponentMeta +NoneTypes = [type(None), None] + # TODO make validation / ValueError / warnings configurable # TODO use repr for warning messages and ensure that they don't get to long @@ -157,11 +159,15 @@ def extract_attr_type(attribute: attr.Attribute) -> Tuple[Optional[Type[MutableS if attr_type is None: raise ValueError("can't convert attribute %s with AttributeConverter, " "as it has no type information" % attribute) + return unwrap_type(attr_type) + + +def unwrap_type(attr_type: Type) -> Tuple[Optional[Type[MutableSequence]], Type, List[Type]]: generic_origin = getattr(attr_type, "__origin__", attr_type) generic_vars = getattr(attr_type, "__args__", tuple()) if generic_origin == Union: - generic_vars = [v for v in generic_vars if v is not type(None)] + generic_vars = [v for v in generic_vars if v not in NoneTypes] if len(generic_vars) > 1: return None, generic_origin[tuple(generic_vars)], list(generic_vars) else: @@ -170,7 +176,9 @@ def extract_attr_type(attribute: attr.Attribute) -> Tuple[Optional[Type[MutableS elif issubclass(generic_origin, MutableSequence): if len(generic_vars) > 1: warnings.warn("using first parameter for List type %s" % attr_type) - return generic_origin, generic_vars[0], [generic_vars[0]] + res = unwrap_type(generic_vars[0]) + assert res[0] is None + return generic_origin, res[1], res[2] else: return None, attr_type, [attr_type] diff --git a/ics/event.py b/ics/event.py index 32ec4908..2ee9f8c2 100644 --- a/ics/event.py +++ b/ics/event.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta -from typing import Any, List, Optional, Tuple +from typing import Any, List, Optional, Tuple, Union import attr from attr.converters import optional as c_optional @@ -13,7 +13,7 @@ from ics.converter.timespan import TimespanConverter from ics.geo import Geo, make_geo from ics.timespan import EventTimespan, Timespan -from ics.types import DatetimeLike, EventOrTimespan, EventOrTimespanOrInstant, TimedeltaLike, get_timespan_if_calendar_entry +from ics.types import DatetimeLike, EventOrTimespan, EventOrTimespanOrInstant, TimedeltaLike, URL, get_timespan_if_calendar_entry from ics.utils import check_is_instance, ensure_datetime, ensure_timedelta, ensure_utc, now_in_utc, uid_gen, validate_not_none STATUS_VALUES = (None, 'TENTATIVE', 'CONFIRMED', 'CANCELLED') @@ -35,6 +35,7 @@ class CalendarEntryAttrs(Component): dtstamp: datetime = attr.ib(factory=now_in_utc, converter=ensure_utc, validator=validate_not_none) # type: ignore alarms: List[BaseAlarm] = attr.ib(factory=list, converter=list) + attach: List[Union[URL, bytes]] = attr.ib(factory=list, converter=list) def __init_subclass__(cls): super().__init_subclass__() diff --git a/ics/icalendar.py b/ics/icalendar.py index 10dbb274..31ab4df4 100644 --- a/ics/icalendar.py +++ b/ics/icalendar.py @@ -1,5 +1,5 @@ from datetime import tzinfo -from typing import ClassVar, Iterable, List, Optional, Union +from typing import ClassVar, Iterable, Iterator, List, Optional, Union import attr from attr.validators import instance_of @@ -7,7 +7,7 @@ from ics.component import Component from ics.converter.component import ComponentMeta from ics.event import Event -from ics.grammar import Container, calendar_string_to_containers +from ics.grammar import Container, string_to_container from ics.timeline import Timeline from ics.todo import Todo @@ -69,7 +69,7 @@ def __init__( if isinstance(imports, Container): self.populate(imports) else: - containers = calendar_string_to_containers(imports) + containers = string_to_container(imports) if len(containers) != 1: raise ValueError("Multiple calendars in one file are not supported by this method." "Use ics.Calendar.parse_multiple()") @@ -89,7 +89,7 @@ def parse_multiple(cls, string): Parses an input string that may contain mutiple calendars and retruns a list of :class:`ics.event.Calendar` """ - containers = calendar_string_to_containers(string) + containers = string_to_container(string) return [cls(imports=c) for c in containers] def __str__(self) -> str: @@ -99,7 +99,7 @@ def __str__(self) -> str: len(self.todos), "s" if len(self.todos) > 1 else "") - def __iter__(self) -> Iterable[str]: + def __iter__(self) -> Iterator[str]: """Returns: iterable: an iterable version of __str__, line per line (with line-endings). From f5c6a161bdcca7b57167b94d927731866e6bcefb Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Fri, 10 Apr 2020 15:46:14 +0200 Subject: [PATCH 16/43] import project files --- doc/advanced.rst | 85 ++++ doc/event-cmp.rst | 353 +++++++++++++++++ doc/event.rst | 98 +++++ doc/explanation/about.rst | 1 + doc/explanation/howto.rst | 64 +++ doc/explanation/icalender-format.rst | 6 + doc/explanation/limitations.rst | 58 +++ doc/howtos/installation.rst | 118 ++++++ doc/howtos/quickstart.rst | 64 +++ doc/index.rst | 56 +++ doc/reference/api.rst | 51 +++ doc/reference/changelog.rst | 4 + doc/tutorials/contributing.rst | 1 + doc/tutorials/create-ics-with-cal-prog.rst | 115 ++++++ mypy.ini | 2 - pyproject.toml | 2 +- src/ics/__init__.py | 42 +- src/ics/__meta__.py | 7 + src/ics/alarm.py | 135 +++++++ src/ics/attendee.py | 30 ++ src/ics/component.py | 66 ++++ src/ics/converter/__init__.py | 0 src/ics/converter/base.py | 206 ++++++++++ src/ics/converter/component.py | 102 +++++ src/ics/converter/special.py | 117 ++++++ src/ics/converter/timespan.py | 125 ++++++ src/ics/converter/value.py | 139 +++++++ src/ics/event.py | 257 ++++++++++++ src/ics/geo.py | 26 ++ src/ics/grammar/__init__.py | 296 ++++++++++++++ src/ics/grammar/contentline.ebnf | 37 ++ src/ics/icalendar.py | 113 ++++++ src/ics/timeline.py | 145 +++++++ src/ics/timespan.py | 435 +++++++++++++++++++++ src/ics/todo.py | 82 ++++ src/ics/types.py | 176 +++++++++ src/ics/utils.py | 228 +++++++++++ src/ics/valuetype/__init__.py | 0 src/ics/valuetype/base.py | 50 +++ src/ics/valuetype/datetime.py | 294 ++++++++++++++ src/ics/valuetype/generic.py | 144 +++++++ src/ics/valuetype/special.py | 25 ++ src/ics/valuetype/text.py | 68 ++++ tests/grammar/__init__.py | 288 ++++++++++++++ tests/test_ics.py | 5 - tests/valuetype/__init__.py | 0 tests/valuetype/text.py | 80 ++++ 47 files changed, 4787 insertions(+), 9 deletions(-) create mode 100644 doc/advanced.rst create mode 100644 doc/event-cmp.rst create mode 100644 doc/event.rst create mode 100644 doc/explanation/about.rst create mode 100644 doc/explanation/howto.rst create mode 100644 doc/explanation/icalender-format.rst create mode 100644 doc/explanation/limitations.rst create mode 100644 doc/howtos/installation.rst create mode 100644 doc/howtos/quickstart.rst create mode 100644 doc/reference/api.rst create mode 100644 doc/reference/changelog.rst create mode 100644 doc/tutorials/contributing.rst create mode 100644 doc/tutorials/create-ics-with-cal-prog.rst create mode 100644 src/ics/__meta__.py create mode 100644 src/ics/alarm.py create mode 100644 src/ics/attendee.py create mode 100644 src/ics/component.py create mode 100644 src/ics/converter/__init__.py create mode 100644 src/ics/converter/base.py create mode 100644 src/ics/converter/component.py create mode 100644 src/ics/converter/special.py create mode 100644 src/ics/converter/timespan.py create mode 100644 src/ics/converter/value.py create mode 100644 src/ics/event.py create mode 100644 src/ics/geo.py create mode 100644 src/ics/grammar/__init__.py create mode 100644 src/ics/grammar/contentline.ebnf create mode 100644 src/ics/icalendar.py create mode 100644 src/ics/timeline.py create mode 100644 src/ics/timespan.py create mode 100644 src/ics/todo.py create mode 100644 src/ics/types.py create mode 100644 src/ics/utils.py create mode 100644 src/ics/valuetype/__init__.py create mode 100644 src/ics/valuetype/base.py create mode 100644 src/ics/valuetype/datetime.py create mode 100644 src/ics/valuetype/generic.py create mode 100644 src/ics/valuetype/special.py create mode 100644 src/ics/valuetype/text.py create mode 100644 tests/grammar/__init__.py delete mode 100644 tests/test_ics.py create mode 100644 tests/valuetype/__init__.py create mode 100644 tests/valuetype/text.py diff --git a/doc/advanced.rst b/doc/advanced.rst new file mode 100644 index 00000000..64bcd67d --- /dev/null +++ b/doc/advanced.rst @@ -0,0 +1,85 @@ +.. _`advanced`: + +Advanced usage +============== +This page will present some more advanced usage of ics.py +as well as some parts of the low level API. + +.. Low level constructs +.. -------------------- + +.. _`custom-property`: + +Custom properties +----------------- + +ics.py does not indeed support the full rfc5545 specification +and most likely, it will never do as it is too much work. +Also, there are as many extensions to the RFC as there are implementations +of iCalendar creators so it would be impossible to support every existing +property. + +The way around this limitation is that every :class:`ics.parse.Container` +(:class:`~ics.Event`, :class:`~ics.Todo` and even :class:`~ics.Calendar` +inherit from :class:`~ics.parse.Container`) +has a ``.extra`` attribute. + +At parsing time, every property or container that is unknown to ics.py +(and thus not handled) is stored in ``.extra``. The other way, everything +in ``.extra`` is serialized when outputting data. + +Let's say that we have an input like this +(indentation is for illustration purposes): :: + + BEGIN:VEVENT + SUMMARY:Name of the event + FOO:BAR + END:VEVENT + +It will result in an event that will have the following characteristics: + +.. code-block:: python + + e.name == "Name of the event" + e.extra == [ContentLine(name="FOO", value="BAR")] + +In a more complicated situation, you might even have +something like this: :: + + BEGIN:VEVENT + SUMMARY:Name of the event + BEGIN:FOO + BAR:MISC + END:FOO + THX:BYE + END:VEVENT + +It will result in an event that will have a :class:`ics.parse.Container` +in ``.extra``: + +.. code-block:: python + + e.name == "Name of the event" + e.extra == [ + Container( + name="FOO", + ContentLine(name="BAR", value="MISC") + ), + ContentLine(name="THX", value="BYE") + ] + +``.extra`` is mutable so this means it works in reverse too. + +Just add some :class:`~ics.parse.Container` or +:class:`ics.parse.ContentLine` and they will appear in the output too. +(You can even mutate the values of a specific :class:`~ics.parse.ContentLine` +if you desire) + +Low level API +------------- + +.. autoclass:: ics.parse.Container + :members: + +.. autoclass:: ics.parse.ContentLine + :members: diff --git a/doc/event-cmp.rst b/doc/event-cmp.rst new file mode 100644 index 00000000..111a759a --- /dev/null +++ b/doc/event-cmp.rst @@ -0,0 +1,353 @@ +Events, Todos and also the Timespans they represent can be compared +using the usual python comparision operators ``<``, ``>``, ``<=``, +``>=``, ``==``, ``!=``. This also means that a list of Events can be +sorted by a call to ``sort``. See the following sections for details on +how this works in different cases. + +Equality +-------- + +The methods ``__eq__`` and ``__ne__`` implementing ``==`` and ``!=`` are +generated by ``attrs`` based on *all* public attributes of the +respective class. For an Event, this also includes things like the +automatically generated ``UID`` and the timestamps ``created``, +``last_modified`` and ``dtstamp``, where the latter defaults to +``datetime.now``. As the ``UID``\ s are randomly generated and also even +two consecutive calls to ``datetime.now()`` usually yield different +results, the same holds for constructing two events in sequence: + +:: + + >>> from datetime import datetime + >>> datetime.now() == datetime.now() + False + >>> import ics + >>> e1, e2 = ics.Event(), ics.Event() + >>> e1 == e2 + False + >>> e1.uid = e2.uid = "event1" + >>> e1.dtstamp = e2.dtstamp = datetime.now() + >>> e1 == e2 + True + +Also note that for any list of objects, e.g. the list of alarms of an +Event, the order is important… + +:: + + >>> from datetime import datetime as dt, timedelta as td + >>> e1.alarms.append(ics.DisplayAlarm(trigger=td(days=-1), display_text="Alarm 1")) + >>> e1.alarms.append(ics.DisplayAlarm(trigger=td(hours=-1), display_text="Alarm 2")) + >>> e2.alarms = list(reversed(e1.alarms)) + >>> e1 == e2 + False + >>> e2.alarms = list(e1.alarms) + >>> e1 == e2 + True + +…and also the ``extra`` Container with custom ``ContentLine``\ s, which +is especially important when parsing ics files that contain unknown +properties. + +:: + + >>> e1.extra.append(ics.ContentLine("X-PRIORITY", value="HIGH")) + >>> e1 == e2 + False + +Private attributes, such as Components’ ``_classmethod_args``, +``_classmethod_kwargs`` and iCalendars’ ``_timezones`` are excluded from +comparision. If you want to know the exact differences between two +Events, either convert the events to their ics representation using +``str(e)`` or use the ``attr.asdict`` method to get a dict with all +attributes. + +:: + + >>> e = ics.Event() + >>> e # doctest: +ELLIPSIS + Event(extra=Container('VEVENT', []), extra_params={}, _timespan=EventTimespan(begin_time=None, end_time=None, duration=None, precision='second'), summary=None, uid='...@....org', description=None, location=None, url=None, status=None, created=None, last_modified=None, dtstamp=datetime.datetime(2020, ..., tzinfo=tzutc()), alarms=[], attach=[], classification=None, transparent=None, organizer=None, geo=None, attendees=[], categories=[]) + >>> str(e) + '' + >>> e.serialize() # doctest: +ELLIPSIS + 'BEGIN:VEVENT\r\nUID:...@...org\r\nDTSTAMP:2020...T...Z\r\nEND:VEVENT' + >>> import attr, pprint + >>> pprint.pprint(attr.asdict(e)) # doctest: +ELLIPSIS + {'_timespan': {'begin_time': None, + 'duration': None, + 'end_time': None, + 'precision': 'second'}, + 'alarms': [], + 'attach': [], + 'attendees': [], + 'categories': [], + 'classification': None, + 'created': None, + 'description': None, + 'dtstamp': datetime.datetime(2020, ..., tzinfo=tzutc()), + 'extra': {'data': [], 'name': 'VEVENT'}, + 'extra_params': {}, + 'geo': None, + 'last_modified': None, + 'location': None, + 'organizer': None, + 'status': None, + 'summary': None, + 'transparent': None, + 'uid': '...@....org', + 'url': None} + +Ordering +-------- + +TL;DR: ``Event``\ s are ordered by their attributes ``begin``, ``end``, +and ``summary``, in that exact order. For ``Todo``\ s the order is ``due``, +``begin``, then ``summary``. It doesn’t matter whether ``duration`` is set +instead of ``end`` or ``due``, as the effective end / due time will be +compared. Instances where an attribute isn’t set will be sorted before +instances where the respective attribute is set. Naive ``datetime``\ s +(those without a timezone) will be compared in local time. + +Implementation +~~~~~~~~~~~~~~ + +The class ``EventTimespan`` used by ``Event`` to represent begin and end +times or durations has a method ``cmp_tuple`` returning the respective +instance as a tuple ``(begin_time, effective_end_time)``: + +:: + + >>> t0 = ics.EventTimespan() + >>> t0.cmp_tuple() + TimespanTuple(begin=datetime.datetime(1, 1, 1, 0, 0, tzinfo=tzlocal()), end=datetime.datetime(1, 1, 1, 0, 0, tzinfo=tzlocal())) + >>> t1 = ics.EventTimespan(begin_time=dt(2020, 2, 20, 20, 20)) + >>> t1.cmp_tuple() + TimespanTuple(begin=datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal()), end=datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal())) + >>> t2 = ics.EventTimespan(begin_time=dt(2020, 2, 20, 20, 20), end_time=dt(2020, 2, 22, 20, 20)) + >>> t2.cmp_tuple() + TimespanTuple(begin=datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal()), end=datetime.datetime(2020, 2, 22, 20, 20, tzinfo=tzlocal())) + +It doesn’t matter whether an end time or a duration was specified for +the timespan, as only the effective end time is compared. + +:: + + >>> t3 = ics.EventTimespan(begin_time=dt(2020, 2, 20, 20, 20), duration=td(days=2)) + >>> t2 < t3 + False + >>> t3 < t2 + False + +The classes ``Event`` and ``Todo`` build on this methods, by appending +their ``summary`` to the returned tuple: + +:: + + >>> e11 = ics.Event(timespan=t1) + >>> e11.cmp_tuple() + (datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal()), datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal()), '') + >>> e12 = ics.Event(timespan=t1, summary="An Event") + >>> e12.cmp_tuple() + (datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal()), datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal()), 'An Event') + +We define ``__lt__`` (i.e. lower-than, or ``<``) explicitly for +``Timespan``, ``Event`` and ``Todo`` based on comparing their +``cmp_tuple``\ s component-wise (as is the default for comparing python +tuples). Please note that neither ``str`` nor ``datetime`` are +comparable as less-than or greater-than ``None``. So string values are +replaced by the empty string ``""`` and the ``datetime`` values are +replaced by ``datetime.min``. This means that instances having no value +for a certain parameter will always be sorted before instances where the +parameter is set: + +:: + + >>> ics.Event(timespan=t0) < ics.Event(timespan=t1) + True + >>> ics.Event(timespan=t1) < ics.Event(timespan=t2) + True + >>> ics.Event(timespan=t2) < ics.Event(timespan=t2, summary="Event Name") + True + +The functions ``__gt__``, ``__le__``, ``__ge__`` all behave similarly by +applying the respective operation to the ``cmp_tuples``. Note that for +``Todo``\ s the attribute ``due`` has higher priority than ``begin``: + +:: + + >>> x1 = ics.Todo(begin=dt(2020, 2, 20, 20, 20)) + >>> x2 = ics.Todo(due=dt(2020, 2, 22, 20, 20)) + >>> x3 = ics.Todo(begin=dt(2020, 2, 20, 20, 20), due=dt(2020, 2, 22, 20, 20)) + >>> x1 < x2 + True + >>> x1.begin = dt(2020, 4, 4, 20, 20) + >>> x1.begin > x2.due + True + >>> x1 < x2 # even altough x2 now completely lies before x1 + True + >>> x2 < x3 + True + +Comparison Caveats +~~~~~~~~~~~~~~~~~~ + +To understand how comparison of events works and what might go wrong in +special cases, one first needs to understand how the “rich comparision” +operators (``__lt__`` and the like) are +`defined `__: + + By default, ``__ne__()`` delegates to ``__eq__()`` and inverts the + result unless it is ``NotImplemented``. There are no other implied + relationships among the comparison operators, for example, the truth + of ``(x`__. Additionally, as +these tuples only represent a part of the instance, the order is not +total and the following caveats need to be considered. The equality part +in ``<=`` only holds for the compared tuples, but not all the remaining +event attributes, thus ``(x<=y and not x y)`` does also *not* imply +``i == y``. See the end of the next section, where this is shown for two +``Timespans`` that refer to the same timestamps, but in different +timezones. + +Unlike all ordering functions, the equality comparision functions +``__eq__`` and ``__ne__`` are generated by +``attr.s(eq=True, ord=False)`` as defined +`here `__: + + They compare the instances as if they were tuples of their attrs + attributes, but only iff the types of both classes are identical! + +This is similar to defining the operations as follows: + +:: + + if other.__class__ is self.__class__: + return attrs_to_tuple(self) attrs_to_tuple(other) + else: + return NotImplemented + +Note that equality, unlike ordering, thus takes all attributes and also +the specific class into account. + +Comparing ``datetime``\ s with and without timezones +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +By default, ``datetime``\ s with timezones and those without timezones +(so called naive ``datetimes``) can’t directly be ordered. Furthermore, +behaviour of some ``datetime`` depends on the local timezone, so let’s +first +`assume `__ we +are all living in Berlin, Germany and have the corresponding timezone +set: + +:: + + >>> import os, time + >>> os.environ['TZ'] = "Europe/Berlin" + >>> time.tzset() + >>> time.tzname + ('CET', 'CEST') + +We can easily compare ``datetime`` instances that have an explicit +timezone specified: + +:: + + >>> from dateutil.tz import tzutc, tzlocal, gettz + >>> dt_ny = dt(2020, 2, 20, 20, 20, tzinfo=gettz("America/New York")) + >>> dt_utc = dt(2020, 2, 20, 20, 20, tzinfo=tzutc()) + >>> dt_local = dt(2020, 2, 20, 20, 20, tzinfo=tzlocal()) + >>> dt_utc < dt_ny + True + >>> dt_local < dt_utc # this always holds as tzlocal is Europe/Berlin + True + +We can also compare naive instances with naive ones, but we can’t +compare naive ones with timezone-aware ones: + +:: + + >>> dt_naive = dt(2020, 2, 20, 20, 20) + >>> dt_naive < dt_local + Traceback (most recent call last): + ... + TypeError: can't compare offset-naive and offset-aware datetimes + +While comparision fails in this case, other methods of ``datetime`` +treat naive instances as local times. This e.g. holds for +```datetime.timestamp()`` `__, +which could also be used for comparing instances: + +:: + + >>> (dt_utc.timestamp(), dt_ny.timestamp()) + (1582230000.0, 1582248000.0) + >>> (dt_local.timestamp(), dt_naive.timestamp()) + (1582226400.0, 1582226400.0) + +This can be become an issue when you e.g. want to iterate all Events of +an iCalendar that contains both floating and timezone-aware Events in +order of their begin timestamp. Let’s consult RFC 5545 on what to do in +this situation: + + DATE-TIME values of this type are said to be “floating” and are not + bound to any time zone in particular. They are used to represent the + same hour, minute, and second value regardless of which time zone is + currently being observed. For example, an event can be defined that + indicates that an individual will be busy from 11:00 AM to 1:00 PM + every day, no matter which time zone the person is in. In these + cases, a local time can be specified. The recipient of an iCalendar + object with a property value consisting of a local time, without any + relative time zone information, SHOULD interpret the value as being + fixed to whatever time zone the “ATTENDEE” is in at any given moment. + This means that two “Attendees”, in different time zones, receiving + the same event definition as a floating time, may be participating in + the event at different actual times. Floating time SHOULD only be + used where that is the reasonable behavior. + +Thus, clients should default to local time when handling floating +events, similar to what other datetime methods do. This is also what +ics.py does, handling this in the ``cmp_tuple`` method by always +converting naive ``datetime``\ s to local ones: + +:: + + >>> e_local, e_floating = ics.Event(begin=dt_local), ics.Event(begin=dt_naive) + >>> e_local.begin, e_floating.begin + (datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal()), datetime.datetime(2020, 2, 20, 20, 20)) + >>> e_local.begin == e_floating.begin + False + >>> e_local.timespan.cmp_tuple() + TimespanTuple(begin=datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal()), end=datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal())) + >>> e_floating.timespan.cmp_tuple() + TimespanTuple(begin=datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal()), end=datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal())) + >>> e_local.timespan.cmp_tuple() == e_floating.timespan.cmp_tuple() + True + +So, one floating Event and one Event with explicit timezones can still +be compared, while their begin ``datetime``\ s can’t be directly +compared: + +:: + + >>> e_local < e_floating + False + >>> e_local > e_floating + False + >>> e_local.begin < e_floating.begin + Traceback (most recent call last): + ... + TypeError: can't compare offset-naive and offset-aware datetimes + +Note that neither being considered less than the other hints at both +being ordered equally, but they aren’t exactly equal as ``datetime``\ s +with different timezones can’t be equal. + +:: + + >>> e_local == e_floating + False diff --git a/doc/event.rst b/doc/event.rst new file mode 100644 index 00000000..1c480d58 --- /dev/null +++ b/doc/event.rst @@ -0,0 +1,98 @@ +First, let’s import the latest version of ics.py :date: + +:: + + >>> import ics + >>> ics.__version__ + '0.8dev' + +We’re also going to create a lot of ``datetime`` and ``timedelta`` +objects, so we import them as short-hand aliases ``dt`` and ``td``: + +:: + + >>> from datetime import datetime as dt, timedelta as td + +Now, we are ready to create our first event :tada: + +:: + + >>> e = ics.Event(begin=dt(2020, 2, 20, 20, 20)) + >>> str(e) + '' + +We specified no end time or duration for the event, so the event +defaults to ending at the same instant it begins. The event is also +considered “floating”, as datetime objects by default contain no +timezone information and we didn’t specify any ``tzinfo``, so the begin +time of the event is timezone-naive. We will see how to handle timezones +correctly later. Instead of using the default end time, we can update +the event to set the end time explicitly: + +:: + + >>> e.end = dt(2020, 2, 22, 20, 20) + >>> str(e) + '' + +Now, the duration of the event explicitly shows up and the end time is +also marked as being set or “fixed” to a certain instant. If we now set +the duration of the event to a different value, the end time will change +correspondingly: + +:: + + >>> e.duration=td(days=2) + >>> str(e) + '' + +As we now specified the duration explicitly, the duration is now fixed +instead of the end time. This actually makes a big difference when you +now change the start time of the event: + +:: + + >>> e1 = ics.Event(begin=dt(2020, 2, 20, 20, 20), end=dt(2020, 2, 22, 20, 20)) + >>> e2 = ics.Event(begin=dt(2020, 2, 20, 20, 20), duration=td(days=2)) + >>> str(e1) + '' + >>> str(e2) + '' + >>> e1.begin = e2.begin = dt(2020, 1, 10, 10, 10) + >>> str(e1) + '' + >>> str(e2) + '' + +As we just saw, duration and end can also be passed to the constructor, +but both are only allowed when a begin time for the event is specified, +and both can’t be set at the same time: + +:: + + >>> ics.Event(end=dt(2020, 2, 22, 20, 20)) + Traceback (most recent call last): + ... + ValueError: event timespan without begin time can't have end time + >>> ics.Event(duration=td(2)) + Traceback (most recent call last): + ... + ValueError: timespan without begin time can't have duration + >>> ics.Event(begin=dt(2020, 2, 20, 20, 20), end=dt(2020, 2, 22, 20, 20), duration=td(2)) + Traceback (most recent call last): + ... + ValueError: can't set duration together with end time + +Similarly, when you created an event that hasn’t a begin time yet, you +won’t be able to set its duration or end: + +:: + + >>> ics.Event().end = dt(2020, 2, 22, 20, 20) + Traceback (most recent call last): + ... + ValueError: event timespan without begin time can't have end time + >>> ics.Event().duration = td(2) + Traceback (most recent call last): + ... + ValueError: timespan without begin time can't have duration diff --git a/doc/explanation/about.rst b/doc/explanation/about.rst new file mode 100644 index 00000000..7739272f --- /dev/null +++ b/doc/explanation/about.rst @@ -0,0 +1 @@ +.. include:: ../../AUTHORS.rst diff --git a/doc/explanation/howto.rst b/doc/explanation/howto.rst new file mode 100644 index 00000000..0bf8c2eb --- /dev/null +++ b/doc/explanation/howto.rst @@ -0,0 +1,64 @@ +How to ? +======== + +We got some recurrent/interesting questions on GitHub and +by email [#email]_. Here are some answers that might be interesting +to others. + +Is there a way to export to format *X* ? +---------------------------------------- + +ics.py does not support exporting data to any other file format than +the one specified in the rfc5545 and this is not expected to change. + +Nevertheless, you might want to have a look at the rfc7265 +https://tools.ietf.org/html/rfc7265 +that describes a 1 to 1 conversion between the iCalendar format and +a JSON format. + +You might want to take a look at this implementation +https://github.com/mozilla-comm/ical.js/wiki +Please contact us if you know other good quality implementations of +converters between iCalendar and jCalendar + +There is also no straightforward to export your data to a tabular +format (let's say something like CSV or a Pandas DataFrame) +because the iCalendar is hierarchical *by design*: a VCALENDAR has +multiple VTODO and VEVENT and a VEVENT contains multiple VALARM and +so on. + +ics.py does not support the property *Y*, i'm stuck +---------------------------------------------------- + +Please take a look at :ref:`this section `. + + +Known bugs +---------- + +Issues with all-day events +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The semantics of all-day events in the pyton API were badly defined +in the early versions of ics.py and this led to incoherence and +bugs. See this +`GitHub thread `_ +for more info. + +Datetimes are converted to UTC at parsing time +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +ics.py always uses UTC for internal representation of dates. +This is wrong and leads to many problems. See this +`GitHub thread `_ +for more info. + +.. rubric:: Footnotes + +.. [#email] Please don't send us questions by email, GitHub is much + more suited for questions +.. [#malformed] An exception to this rule is already made with + VALARM. ics.py already any type of ``ACTION`` while the rfc only + accepts a fixed set. +.. [#errors] Known errors are Apple iCloud omitting ``PRODID`` and + badly other client outputting formatted line splits. diff --git a/doc/explanation/icalender-format.rst b/doc/explanation/icalender-format.rst new file mode 100644 index 00000000..63c09682 --- /dev/null +++ b/doc/explanation/icalender-format.rst @@ -0,0 +1,6 @@ +.. _`introduction`: + +About the iCalendar Format +========================== + +.. Contains a very brief information about the ICS format diff --git a/doc/explanation/limitations.rst b/doc/explanation/limitations.rst new file mode 100644 index 00000000..cc6dc28b --- /dev/null +++ b/doc/explanation/limitations.rst @@ -0,0 +1,58 @@ +.. _`misc`: + +Known Limitations +================= + + +ics.py has a some known limitations due to the complexity of some parts +of the rfc5545 specification or the lack of time of the developers. +Here is a non-exhaustive list of the most notable. + +Missing support for recurrent events +------------------------------------ + +Events in the iCalendar specification my have a ``RRULE`` property that +defines a rule or repeating pattern (Todos may have those too). +At the moment, ics.py does not have support for either parsing of this +property of its usage in the :class:`ics.timeline.Timeline` class +as designing a Pythonic API proved challenging. + +Support of ``RRULE`` is expected to be implemented before version 1.0. + +.. _`coverage`: + +Coverage of the rfc5545 is not 100% +----------------------------------- + +ics.py does not indeed support the full rfc5545 specification +and most likely, it will never do as it is too much work. + +Also, there are as many extensions to the RFC as there are implementations +of iCalendar creators so it would be impossible to support every existing +property. + +This does not mean that ics.py can not read your file: +ics should be able to ready any rfc-compliant file. +ics.py is also able to output a file with the specific property +that you want to use without having knowledge of its meaning. +Please have a look at the :ref:`Advanced guide ` to use +the low level API and have access to unsupported properties. + + +ics.py too is strict when parsing input +--------------------------------------- + +ics.py was made to output rfc-compliant iCalendar files +and to when possible parse only valid files. +This means that ics.py will throw exceptions when fed malformed +input [#malformed]_ because we trust that failing silently is +not a good practice. + +However, we noticed that some widely used clients create some malformed +files. We are planning to add options to ignore those errors [#errors]_ or +transforming them into warnings but at the moment, you will have to +fix those before giving inputting them in ics.py. + +.. note:: These problems are not easy to solve in an + elegant way so they are not best suited for a first contribution + nor they are expected be addressed by the maintainers in the near future diff --git a/doc/howtos/installation.rst b/doc/howtos/installation.rst new file mode 100644 index 00000000..639b255d --- /dev/null +++ b/doc/howtos/installation.rst @@ -0,0 +1,118 @@ +Installing ics.py +================= + +.. meta:: + :keywords: install ics.py + :keywords: pip + :keywords: macos + :keywords: linux + :keywords: windows + +.. topic:: Abstract + + In this document, we show you how to install the ics.py package with + the command :command:`pip` on a virtual Python environment and on + your system. + +.. contents:: Content + :local: + + +.. _sec.install.check-python-and-pip: + +Checking your Python & pip version +---------------------------------- + +Make sure you have Python available. Type:: + + $ python --version + +You should get an output like ``3.6.10``. This package needs a minimal +version of Python |minpyver|. + +Some systems still distinguish between Python 2 and Python 3. Since 2020, +Python 2 is deprecated. If the command above replies with ``2.7.x``, you +should replace the command :command:`python` with :command:`python3` and +run it again. + + +.. _sec.install.with-pip: + +Installing with pip +------------------- + +What is pip? +~~~~~~~~~~~~ + +The command :command:`pip` [#pip]_ is the Python package manager. Usually, you use a +:command:`pip` in combination with a *virtual Python environment* to install +packages. + + +What is a "Virtual Python Environment"? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A virtual Python environment "is an isolated runtime environment that allows +Python users and applications to install and upgrade Python distribution +packages without interfering with the behaviour of other Python applications +running on the same system."[#pyvirtenv]_ + +Install ics.py with pip +~~~~~~~~~~~~~~~~~~~~~~~ + +To use :command:`pip`, you can install the ics.py package with or without a +virtual Python environment: + +**With a virtual Python environment (preferred)** + + #. Start a terminal (in Windows: open up a command prompt window). + #. In your project directory, create a virtual Python environment first: + + * For Linux and MacOS, run:: + + $ python3 -m venv .env + + * For Windows, run:: + + > python -m venv %systemdrive%%homepath%\.env + + #. Activate the environment: + + * For Linux and MacOS, run:: + + $ source .env/bin/activate + + * For Windows, run:: + + > %systemdrive%%homepath%\.env\Scripts\activate.bat + + The prompt will change and show ``(.env)``. + + #. Install the package:: + + $ pip install ics.py + + #. If you do not need the virtual Python environment anymore, run + the command :command:`deactivate`. + + +**Without a virtual Python environment** + + Depending on your operating system, you need root privileges to install + a package with :command:`pip`. To avoid compromising your system + installation, we recommend to use a virtual Python environment. + + However, if you really want to, run:: + + $ pip install ics.py + + +Regardless which method you use, the command :command:`pip list` shows you a +list of all installed packages. The list should contain the ics.py package. + + + +.. rubric:: Footnotes + +.. [#pip] https://pip.pypa.io +.. [#pyvirtenv] Taken from https://docs.python.org/3/glossary.html#term-virtual-environment diff --git a/doc/howtos/quickstart.rst b/doc/howtos/quickstart.rst new file mode 100644 index 00000000..d8e60039 --- /dev/null +++ b/doc/howtos/quickstart.rst @@ -0,0 +1,64 @@ +Quickstart +========== + +.. meta:: + :keywords: quickstart + +.. topic:: Abstract + + In this document, we show you how to make first contact with ics.py. + +.. contents:: Content + :local: + + + +Importing a Calendar from a File +-------------------------------- + +.. code-block:: python + + from ics import Calendar + import requests + + url = "https://urlab.be/events/urlab.ics" + c = Calendar(requests.get(url).text) + + c + # + c.events + # {, + # , + # ...} + e = list(c.timeline)[0] + "Event '{}' started {}".format(e.name, e.begin.humanize()) + # "Event 'Workshop Git' started 2 years ago" + + +Creating a new Calendar and Add Events +-------------------------------------- + +.. code-block:: python + + from ics import Calendar, Event + c = Calendar() + e = Event() + e.name = "My cool event" + e.begin = '20140101 00:00:00' + c.events.add(e) + c.events + # {} + + +Exporting a Calendar to a File +------------------------------ + +.. code-block:: python + + with open('my.ics', 'w') as f: + f.write(c) + # And it's done ! + + # iCalendar-formatted data is also available in a string + str(c) + # 'BEGIN:VCALENDAR\nPRODID:... diff --git a/doc/index.rst b/doc/index.rst index 895c65c6..353144d4 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -14,6 +14,62 @@ If you do not want to deal with the complex iCalendar specification, then ics.py is for you! +First Steps +=========== + +New to our ics.py library? This is the place to start! + +.. toctree:: + :maxdepth: 2 + + howtos/quickstart + howtos/installation + + +Tutorials +========= + +If you search for something specific after you have read the *First Steps*, +find it here. + +.. toctree:: + :maxdepth: 1 + + tutorials/create-ics-with-cal-prog + tutorials/contributing + +Examples for tutorials: + +* Create your first calendar +* TBD + + +Details +======= + +Want to learn some details? Find here the relevant information. + +.. toctree:: + :maxdepth: 2 + + explanation/icalender-format + explanation/limitations + explanation/howto + explanation/about + + +Reference +========= + +If you want to dig a bit deeper in ics.py, here is the place to go. + +.. toctree:: + :maxdepth: 2 + + reference/api + reference/changelog + + Indices and Tables ================== diff --git a/doc/reference/api.rst b/doc/reference/api.rst new file mode 100644 index 00000000..a243d310 --- /dev/null +++ b/doc/reference/api.rst @@ -0,0 +1,51 @@ +API description +=============== + +Calendar +-------- + +.. autoclass:: ics.icalendar.Calendar + :members: + :special-members: + +Event +----- + +.. autoclass:: ics.event.Event + :members: + :special-members: + +Alarms +------ + +.. autoclass:: ics.alarm.base.BaseAlarm + :members: + :special-members: + +.. autoclass:: ics.alarm.AudioAlarm + :members: + :special-members: + +.. autoclass:: ics.alarm.DisplayAlarm + :members: + :special-members: + +.. autoclass:: ics.alarm.EmailAlarm + :members: + :special-members: + +.. autoclass:: ics.alarm.none.NoneAlarm + :members: + :special-members: + +.. autoclass:: ics.alarm.custom.CustomAlarm + :members: + :special-members: + +Timeline +--------- + + +.. autoclass:: ics.timeline.Timeline + :members: + :special-members: diff --git a/doc/reference/changelog.rst b/doc/reference/changelog.rst new file mode 100644 index 00000000..ba087b1a --- /dev/null +++ b/doc/reference/changelog.rst @@ -0,0 +1,4 @@ +Changelog +========= + +Contains all notable changes to the code base. diff --git a/doc/tutorials/contributing.rst b/doc/tutorials/contributing.rst new file mode 100644 index 00000000..ac7b6bcf --- /dev/null +++ b/doc/tutorials/contributing.rst @@ -0,0 +1 @@ +.. include:: ../../CONTRIBUTING.rst diff --git a/doc/tutorials/create-ics-with-cal-prog.rst b/doc/tutorials/create-ics-with-cal-prog.rst new file mode 100644 index 00000000..862cfeae --- /dev/null +++ b/doc/tutorials/create-ics-with-cal-prog.rst @@ -0,0 +1,115 @@ +Create a ICS File with Thunderbird +================================== + +.. meta:: + :keywords: Thunderbird + :keywords: export calendar + +.. topic:: Abstract + + In this document, we introduce Thunderbird. Although it's a classical e-mail + program, but we use it solely as a calendar. + This tutorial shows how to create a calendar, add some entires, and + to export its calendar content to a ``.ics`` file. This can be helpful, + if you want to either test, access certain functions, or play with ics.py. + +.. contents:: Content + :local: + + +.. _sec.tb.installing: + +Installing Thunderbird +---------------------- + +Thunderbird is available for Linux, MacOS, and Windows. + + +Installing on Linux +~~~~~~~~~~~~~~~~~~~ + +For most cases, Thunderbird is already packaged for your Linux distribution. +This means, you can use the package manager of your system. It could be +that your system has an older version, but for this tutorial, this does not +matter. To install Thunderbird on your system, you need to become root, +the system administrator. + +Use the following commands: + +* Debian/Ubuntu:: + + $ sudo apt-get update + $ sudo apt-get install thunderbird + +* Fedora:: + + $ dnf install thunderbird + +* openSUSE:: + + $ sudo zypper install thunderbird + + +Installing on MacOS +~~~~~~~~~~~~~~~~~~~ + +Follow the article on https://support.mozilla.org/en-US/kb/installing-thunderbird-on-mac + + + +Installing on Windows +~~~~~~~~~~~~~~~~~~~~~ + +Follow the article on https://support.mozilla.org/en-US/kb/installing-thunderbird-windows + + +.. _sec.tb.create-calendar: + +Creating a Calendar in Thunderbird +---------------------------------- + +First we need a calendar to attach all entries to it. Proceed as follows: + +#. Start Thunderbird. +#. From the menu, choose :menuselection:`File --> New --> Calendar`. + If the menu is hidden, hit the :kbd:`F10` key. +#. Leave the option :guilabel:`On My Computer` (default) unchanged. Click :guilabel:`Next`. +#. Set a name for your calendar and an optional color. You can leave the + Email to :guilabel:`None`. +#. Click :guilabel:`Next` and then :guilabel:`Finish`. + + +.. _sec.tb.add-entries: + +Adding Events to Your Calendar +------------------------------ + +#. From the menu, choose :menuselection:`File --> New --> Event...` +#. Enter a title. +#. Make sure that the :guilabel:`Calendar:` field contains the name of your + calendar that you have created in section :ref:`sec.tb.create-calendar`. + This is important as if you choose the wrong calendar, your entry will + end up in a completely different location (and is not available in the + expected file). +#. Set the start and (optionally) the end date. If needed, add a time or + check All day Event. +#. Optionally, you can set a recurring date, a reminder and a description. +#. When finished, click Save and Close. +#. Repeat the above steps as needed. + + +.. _sec.tb.export: + +Exporting All Entries from a Calendar +------------------------------------- + +After you have added one or more entires to your calender, export it to +a ics file: + +#. From the menu, choose :menuselection:`Events and Tasks --> Export...` +#. In the dialog box, choose the calendar you want to export and proceed + with Ok. +#. Choose the file location of the ics file. Make sure you use + :guilabel:`iCalendar (*.ics)` as filter. Click :guilabel:`Save` to finish. + +Your calendar is exported as ics file and can be used. diff --git a/mypy.ini b/mypy.ini index 08d9407a..952ed8c4 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,8 +1,6 @@ [mypy] python_version = 3.6 -warn_return_any = True warn_unused_configs = True -disallow_untyped_defs = True [mypy-tests.*] ignore_errors = True diff --git a/pyproject.toml b/pyproject.toml index 87fa773f..4a793409 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ics" -version = "0.1.0" +version = "0.2.0" description = "Pythonic iCalendar (RFC 5545) Parser" authors = ["Nikita Marchant ", "Niko Fink "] license = "Apache-2.0" diff --git a/src/ics/__init__.py b/src/ics/__init__.py index b794fd40..50a62f94 100644 --- a/src/ics/__init__.py +++ b/src/ics/__init__.py @@ -1 +1,41 @@ -__version__ = '0.1.0' +def load_converters(): + from ics.converter.base import AttributeConverter + from ics.converter.component import ComponentConverter + from ics.converter.special import TimezoneConverter, AlarmConverter, PersonConverter, RecurrenceConverter + from ics.converter.timespan import TimespanConverter + from ics.converter.value import AttributeValueConverter + from ics.valuetype.base import ValueConverter + from ics.valuetype.datetime import DateConverter, DatetimeConverter, DurationConverter, PeriodConverter, TimeConverter, UTCOffsetConverter + from ics.valuetype.generic import BinaryConverter, BooleanConverter, CalendarUserAddressConverter, FloatConverter, IntegerConverter, RecurConverter, URIConverter + from ics.valuetype.text import TextConverter + from ics.valuetype.special import GeoConverter + + +load_converters() # make sure that converters are initialized before any Component classes are defined + +from .__meta__ import * # noqa +from .__meta__ import __all__ as all_meta +from .alarm import * # noqa +from .alarm import __all__ as all_alarms +from .attendee import Attendee, Organizer +from .component import Component +from .event import Event +from .geo import Geo +from .grammar import Container, ContentLine +from .icalendar import Calendar +from .timespan import EventTimespan, Timespan, TodoTimespan +from .todo import Todo + +__all__ = [ + *all_meta, + *all_alarms, + "Attendee", + "Event", + "Calendar", + "Organizer", + "Timespan", + "EventTimespan", + "TodoTimespan", + "Todo", + "Component" +] diff --git a/src/ics/__meta__.py b/src/ics/__meta__.py new file mode 100644 index 00000000..fec9635b --- /dev/null +++ b/src/ics/__meta__.py @@ -0,0 +1,7 @@ +__title__ = "ics" +__version__ = "0.8dev" +__author__ = "Nikita Marchant" +__license__ = "Apache License, Version 2.0" +__copyright__ = "Copyright 2013-2020 Nikita Marchant and individual contributors" + +__all__ = ["__title__", "__version__", "__author__", "__license__", "__copyright__"] diff --git a/src/ics/alarm.py b/src/ics/alarm.py new file mode 100644 index 00000000..8a76c277 --- /dev/null +++ b/src/ics/alarm.py @@ -0,0 +1,135 @@ +from abc import ABCMeta, abstractmethod +from datetime import datetime, timedelta +from typing import List, Union + +import attr +from attr.converters import optional as c_optional +from attr.validators import instance_of, optional as v_optional + +from ics.attendee import Attendee +from ics.component import Component +from ics.converter.component import ComponentMeta +from ics.converter.special import AlarmConverter +from ics.types import URL +from ics.utils import call_validate_on_inst, check_is_instance, ensure_timedelta + +__all__ = ["BaseAlarm", "AudioAlarm", "CustomAlarm", "DisplayAlarm", "EmailAlarm", "NoneAlarm"] + + +@attr.s +class BaseAlarm(Component, metaclass=ABCMeta): + """ + A calendar event VALARM base class + """ + Meta = ComponentMeta("VALARM", converter_class=AlarmConverter) + + trigger: Union[timedelta, datetime, None] = attr.ib( + default=None, + validator=v_optional(instance_of((timedelta, datetime))) # type: ignore + ) # TODO is this relative to begin or end? + repeat: int = attr.ib(default=None, validator=call_validate_on_inst) + duration: timedelta = attr.ib(default=None, converter=c_optional(ensure_timedelta), validator=call_validate_on_inst) # type: ignore + + # FIXME: `attach` can be specified multiple times in a "VEVENT", "VTODO", "VJOURNAL", or "VALARM" calendar component + # with the exception of AUDIO alarm that only allows this property to occur once. + # (This property is used in "VALARM" calendar components to specify an audio sound resource or an email message attachment.) + + def validate(self, attr=None, value=None): + if self.repeat is not None: + if self.repeat < 0: + raise ValueError("Repeat must be great than or equal to 0.") + if self.duration is None: + raise ValueError( + "A definition of an alarm with a repeating trigger MUST include both the DURATION and REPEAT properties." + ) + + if self.duration is not None and self.duration.total_seconds() < 0: + raise ValueError("Alarm duration timespan must be positive.") + + @property + @abstractmethod + def action(self): + """ VALARM action to be implemented by concrete classes """ + pass + + +@attr.s +class AudioAlarm(BaseAlarm): + """ + A calendar event VALARM with AUDIO option. + """ + + attach: Union[URL, bytes] = attr.ib(default="") # type: ignore + + @property + def action(self): + return "AUDIO" + + +@attr.s +class CustomAlarm(BaseAlarm): + """ + A calendar event VALARM with custom ACTION. + """ + + _action = attr.ib(default=None) + + @property + def action(self): + return self._action + + +@attr.s +class DisplayAlarm(BaseAlarm): + """ + A calendar event VALARM with DISPLAY option. + """ + + display_text: str = attr.ib(default=None) + + @property + def action(self): + return "DISPLAY" + + +@attr.s +class EmailAlarm(BaseAlarm): + """ + A calendar event VALARM with Email option. + """ + + subject: str = attr.ib(default=None) + body: str = attr.ib(default=None) + recipients: List[Attendee] = attr.ib(factory=list) + + def add_recipient(self, recipient: Attendee): + """ Add an recipient to the recipients list """ + check_is_instance("recipient", recipient, Attendee) + self.recipients.append(recipient) + + @property + def action(self): + return "EMAIL" + + +class NoneAlarm(BaseAlarm): + """ + A calendar event VALARM with NONE option. + """ + + @property + def action(self): + return "NONE" + + +def get_type_from_action(action_type): + if action_type == "DISPLAY": + return DisplayAlarm + elif action_type == "AUDIO": + return AudioAlarm + elif action_type == "NONE": + return NoneAlarm + elif action_type == "EMAIL": + return EmailAlarm + else: + return CustomAlarm diff --git a/src/ics/attendee.py b/src/ics/attendee.py new file mode 100644 index 00000000..330bc50c --- /dev/null +++ b/src/ics/attendee.py @@ -0,0 +1,30 @@ +from typing import Dict, List, Optional + +import attr + +from ics.converter.component import ComponentMeta + + +@attr.s +class Person(object): + email: str = attr.ib() + common_name: str = attr.ib(default=None) + dir: Optional[str] = attr.ib(default=None) + sent_by: Optional[str] = attr.ib(default=None) + extra: Dict[str, List[str]] = attr.ib(factory=dict) + + Meta = ComponentMeta("ABSTRACT-PERSON") + + +class Organizer(Person): + Meta = ComponentMeta("ORGANIZER") + + +@attr.s +class Attendee(Person): + rsvp: Optional[bool] = attr.ib(default=None) + role: Optional[str] = attr.ib(default=None) + partstat: Optional[str] = attr.ib(default=None) + cutype: Optional[str] = attr.ib(default=None) + + Meta = ComponentMeta("ATTENDEE") diff --git a/src/ics/component.py b/src/ics/component.py new file mode 100644 index 00000000..6c52635b --- /dev/null +++ b/src/ics/component.py @@ -0,0 +1,66 @@ +from typing import ClassVar, Dict, List, Type, TypeVar, Union + +import attr +from attr.validators import instance_of + +from ics.converter.component import ComponentMeta, InflatedComponentMeta +from ics.grammar import Container +from ics.types import ExtraParams, RuntimeAttrValidation + +PLACEHOLDER_CONTAINER = Container("PLACEHOLDER") +ComponentType = TypeVar('ComponentType', bound='Component') +ComponentExtraParams = Dict[str, Union[ExtraParams, List[ExtraParams]]] + + +@attr.s +class Component(RuntimeAttrValidation): + Meta: ClassVar[Union[ComponentMeta, InflatedComponentMeta]] = ComponentMeta("ABSTRACT-COMPONENT") + + extra: Container = attr.ib(init=False, default=PLACEHOLDER_CONTAINER, validator=instance_of(Container), metadata={"ics_ignore": True}) + extra_params: ComponentExtraParams = attr.ib(init=False, factory=dict, validator=instance_of(dict), metadata={"ics_ignore": True}) + + def __attrs_post_init__(self): + super(Component, self).__attrs_post_init__() + if self.extra is PLACEHOLDER_CONTAINER: + self.extra = Container(self.Meta.container_name) + + def __init_subclass__(cls): + super().__init_subclass__() + cls.Meta.inflate(cls) + + @classmethod + def from_container(cls: Type[ComponentType], container: Container) -> ComponentType: + return cls.Meta.load_instance(container) # type: ignore + + def populate(self, container: Container): + self.Meta.populate_instance(self, container) # type: ignore + + def to_container(self) -> Container: + return self.Meta.serialize_toplevel(self) # type: ignore + + def serialize(self) -> str: + return self.to_container().serialize() + + def strip_extras(self, all_extras=False, extra_properties=None, extra_params=None, property_merging=None): + if extra_properties is None: + extra_properties = all_extras + if extra_params is None: + extra_params = all_extras + if property_merging is None: + property_merging = all_extras + if not any([extra_properties, extra_params, property_merging]): + raise ValueError("need to strip at least one thing") + if extra_properties: + self.extra.clear() + if extra_params: + self.extra_params.clear() + elif property_merging: + for val in self.extra_params.values(): + if not isinstance(val, list): continue + for v in val: + v.pop("__merge_next", None) + + def clone(self): + """Returns an exact (shallow) copy of self""" + # TODO deep copies? + return attr.evolve(self) diff --git a/src/ics/converter/__init__.py b/src/ics/converter/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/ics/converter/base.py b/src/ics/converter/base.py new file mode 100644 index 00000000..cd9cf90e --- /dev/null +++ b/src/ics/converter/base.py @@ -0,0 +1,206 @@ +import abc +import warnings +from typing import Any, ClassVar, Dict, List, MutableSequence, Optional, TYPE_CHECKING, Tuple, Type, Union, cast + +import attr + +from ics.grammar import Container +from ics.types import ContainerItem, ContextDict, ExtraParams + +if TYPE_CHECKING: + from ics.component import Component + from ics.converter.component import InflatedComponentMeta + +NoneTypes = [type(None), None] + + +# TODO make validation / ValueError / warnings configurable +# TODO use repr for warning messages and ensure that they don't get to long + +class GenericConverter(abc.ABC): + @property + @abc.abstractmethod + def priority(self) -> int: + pass + + @property + @abc.abstractmethod + def filter_ics_names(self) -> List[str]: + pass + + @abc.abstractmethod + def populate(self, component: "Component", item: ContainerItem, context: ContextDict) -> bool: + """ + :param context: + :param component: + :param item: + :return: True, if the line was consumed and shouldn't be stored as extra (but might still be passed on) + """ + pass + + def finalize(self, component: "Component", context: ContextDict): + pass + + @abc.abstractmethod + def serialize(self, component: "Component", output: Container, context: ContextDict): + pass + + +@attr.s(frozen=True) +class AttributeConverter(GenericConverter, abc.ABC): + BY_TYPE: ClassVar[Dict[Type, Type["AttributeConverter"]]] = {} + + attribute: attr.Attribute = attr.ib() + + multi_value_type: Optional[Type[MutableSequence]] + value_type: Type + value_types: List[Type] + _priority: int + is_required: bool + + def __attrs_post_init__(self): + multi_value_type, value_type, value_types = extract_attr_type(self.attribute) + _priority = self.attribute.metadata.get("ics_priority", self.default_priority) + is_required = self.attribute.metadata.get("ics_required", None) + if is_required is None: + if not self.attribute.init: + is_required = False + elif self.attribute.default is not attr.NOTHING: + is_required = False + else: + is_required = True + for key, value in locals().items(): # all variables created in __attrs_post_init__ will be set on self + if key == "self" or key.startswith("__"): continue + object.__setattr__(self, key, value) + + def _check_component(self, component: "Component", context: ContextDict): + if context[(self, "current_component")] is None: + context[(self, "current_component")] = component + context[(self, "current_value_count")] = 0 + else: + if context[(self, "current_component")] is not component: + raise ValueError("must call finalize before call to populate with another component") + + def finalize(self, component: "Component", context: ContextDict): + context[(self, "current_component")] = None + context[(self, "current_value_count")] = 0 + + def set_or_append_value(self, component: "Component", value: Any): + if self.multi_value_type is not None: + container = getattr(component, self.attribute.name) + if container is None: + container = self.multi_value_type() + setattr(component, self.attribute.name, container) + container.append(value) + else: + setattr(component, self.attribute.name, value) + + def get_value(self, component: "Component") -> Any: + return getattr(component, self.attribute.name) + + def get_value_list(self, component: "Component") -> List[Any]: + if self.is_multi_value: + return list(self.get_value(component)) + else: + return [self.get_value(component)] + + def set_or_append_extra_params(self, component: "Component", value: ExtraParams, name: Optional[str] = None): + name = name or self.attribute.name + if self.is_multi_value: + extras = component.extra_params.setdefault(name, []) + cast(List[ExtraParams], extras).append(value) + elif value: + component.extra_params[name] = value + + def get_extra_params(self, component: "Component", name: Optional[str] = None) -> Union[ExtraParams, List[ExtraParams]]: + if self.multi_value_type: + default: Union[ExtraParams, List[ExtraParams]] = cast(List[ExtraParams], list()) + else: + default = ExtraParams(dict()) + name = name or self.attribute.name + return component.extra_params.get(name, default) + + @property + def default_priority(self) -> int: + return 0 + + @property + def priority(self) -> int: + return self._priority + + @property + def is_multi_value(self) -> bool: + return self.multi_value_type is not None + + @staticmethod + def get_converter_for(attribute: attr.Attribute) -> Optional["AttributeConverter"]: + if attribute.metadata.get("ics_ignore", not attribute.init): + return None + converter = attribute.metadata.get("ics_converter", None) + if converter: + return converter(attribute) + + multi_value_type, value_type, value_types = extract_attr_type(attribute) + if len(value_types) == 1: + assert [value_type] == value_types + from ics.component import Component + if issubclass(value_type, Component): + meta: "InflatedComponentMeta" = cast("InflatedComponentMeta", value_type.Meta) + return meta(attribute) + elif value_type in AttributeConverter.BY_TYPE: + return AttributeConverter.BY_TYPE[value_type](attribute) + + from ics.converter.value import AttributeValueConverter + return AttributeValueConverter(attribute) + + +def extract_attr_type(attribute: attr.Attribute) -> Tuple[Optional[Type[MutableSequence]], Type, List[Type]]: + attr_type = attribute.metadata.get("ics_type", attribute.type) + if attr_type is None: + raise ValueError("can't convert attribute %s with AttributeConverter, " + "as it has no type information" % attribute) + return unwrap_type(attr_type) + + +def unwrap_type(attr_type: Type) -> Tuple[Optional[Type[MutableSequence]], Type, List[Type]]: + generic_origin = getattr(attr_type, "__origin__", attr_type) + generic_vars = getattr(attr_type, "__args__", tuple()) + + if generic_origin == Union: + generic_vars = [v for v in generic_vars if v not in NoneTypes] + if len(generic_vars) > 1: + return None, generic_origin[tuple(generic_vars)], list(generic_vars) + else: + return None, generic_vars[0], [generic_vars[0]] + + elif issubclass(generic_origin, MutableSequence): + if len(generic_vars) > 1: + warnings.warn("using first parameter for List type %s" % attr_type) + res = unwrap_type(generic_vars[0]) + assert res[0] is None + return generic_origin, res[1], res[2] + + else: + return None, attr_type, [attr_type] + + +def ics_attr_meta(name: str = None, + ignore: bool = None, + type: Type = None, + required: bool = None, + priority: int = None, + converter: Type[AttributeConverter] = None) -> Dict[str, Any]: + data: Dict[str, Any] = {} + if name: + data["ics_name"] = name + if ignore is not None: + data["ics_ignore"] = ignore + if type is not None: + data["ics_type"] = type + if required is not None: + data["ics_required"] = required + if priority is not None: + data["ics_priority"] = priority + if converter is not None: + data["ics_converter"] = converter + return data diff --git a/src/ics/converter/component.py b/src/ics/converter/component.py new file mode 100644 index 00000000..1baeadba --- /dev/null +++ b/src/ics/converter/component.py @@ -0,0 +1,102 @@ +from collections import defaultdict +from typing import Dict, Iterable, List, Optional, TYPE_CHECKING, Tuple, Type, cast + +import attr +from attr import Attribute + +from ics.converter.base import AttributeConverter, GenericConverter +from ics.grammar import Container +from ics.types import ContainerItem, ContextDict + +if TYPE_CHECKING: + from ics.component import Component + + +@attr.s(frozen=True) +class ComponentMeta(object): + container_name: str = attr.ib() + converter_class: Type["ComponentConverter"] = attr.ib(default=None) + + def inflate(self, component_type: Type["Component"]): + if component_type.Meta is not self: + raise ValueError("can't inflate %s for %s, it's meta is %s" % (self, component_type, component_type.Meta)) + converters = cast(Iterable["AttributeConverter"], filter(bool, ( + AttributeConverter.get_converter_for(a) + for a in attr.fields(component_type) + ))) + component_type.Meta = InflatedComponentMeta( + component_type=component_type, + converters=tuple(sorted(converters, key=lambda c: c.priority)), + container_name=self.container_name, + converter_class=self.converter_class or ComponentConverter) + + +@attr.s(frozen=True) +class InflatedComponentMeta(ComponentMeta): + converters: Tuple[GenericConverter, ...] = attr.ib(default=None) + component_type: Type["Component"] = attr.ib(default=None) + + converter_lookup: Dict[str, List[GenericConverter]] + + def __attrs_post_init__(self): + object.__setattr__(self, "converter_lookup", defaultdict(list)) + for converter in self.converters: + for name in converter.filter_ics_names: + self.converter_lookup[name].append(converter) + + def __call__(self, attribute: Attribute): + return self.converter_class(attribute, self) + + def load_instance(self, container: Container, context: Optional[ContextDict] = None): + instance = self.component_type() + self.populate_instance(instance, container, context) + return instance + + def populate_instance(self, instance: "Component", container: Container, context: Optional[ContextDict] = None): + if container.name != self.container_name: + raise ValueError("container isn't an {}".format(self.container_name)) + if not context: + context = ContextDict(defaultdict(lambda: None)) + + for line in container: + consumed = False + for conv in self.converter_lookup[line.name]: + if conv.populate(instance, line, context): + consumed = True + if not consumed: + instance.extra.append(line) + + for conv in self.converters: + conv.finalize(instance, context) + + def serialize_toplevel(self, component: "Component", context: Optional[ContextDict] = None): + if not context: + context = ContextDict(defaultdict(lambda: None)) + container = Container(self.container_name) + for conv in self.converters: + conv.serialize(component, container, context) + container.extend(component.extra) + return container + + +@attr.s(frozen=True) +class ComponentConverter(AttributeConverter): + meta: InflatedComponentMeta = attr.ib() + + @property + def filter_ics_names(self) -> List[str]: + return [self.meta.container_name] + + def populate(self, component: "Component", item: ContainerItem, context: ContextDict) -> bool: + assert isinstance(item, Container) + self._check_component(component, context) + self.set_or_append_value(component, self.meta.load_instance(item, context)) + return True + + def serialize(self, parent: "Component", output: Container, context: ContextDict): + self._check_component(parent, context) + extras = self.get_extra_params(parent) + if extras: + raise ValueError("ComponentConverter %s can't serialize extra params %s", (self, extras)) + for value in self.get_value_list(parent): + output.append(self.meta.serialize_toplevel(value, context)) diff --git a/src/ics/converter/special.py b/src/ics/converter/special.py new file mode 100644 index 00000000..aca62652 --- /dev/null +++ b/src/ics/converter/special.py @@ -0,0 +1,117 @@ +from datetime import tzinfo +from io import StringIO +from typing import List, TYPE_CHECKING + +from dateutil.rrule import rruleset +from dateutil.tz import tzical + +from ics.attendee import Attendee, Organizer, Person +from ics.converter.base import AttributeConverter +from ics.converter.component import ComponentConverter +from ics.grammar import Container, ContentLine +from ics.types import ContainerItem, ContextDict + +if TYPE_CHECKING: + from ics.component import Component + + +class TimezoneConverter(AttributeConverter): + @property + def filter_ics_names(self) -> List[str]: + return ["VTIMEZONE"] + + def populate(self, component: "Component", item: ContainerItem, context: ContextDict) -> bool: + assert isinstance(item, Container) + self._check_component(component, context) + + item = item.clone([ + line for line in item if + not line.name.startswith("X-") and + not line.name == "SEQUENCE" + ]) + + fake_file = StringIO() + fake_file.write(item.serialize()) # Represent the block as a string + fake_file.seek(0) + timezones = tzical(fake_file) # tzical does not like strings + + # timezones is a tzical object and could contain multiple timezones + print("got timezone", timezones.keys(), timezones.get()) + self.set_or_append_value(component, timezones.get()) + return True + + def serialize(self, component: "Component", output: Container, context: ContextDict): + for tz in self.get_value_list(component): + raise NotImplementedError("Timezones can't be serialized") + + +AttributeConverter.BY_TYPE[tzinfo] = TimezoneConverter + + +class RecurrenceConverter(AttributeConverter): + # TODO handle extras? + # TODO pass and handle available_tz / tzinfos + + @property + def filter_ics_names(self) -> List[str]: + return ["RRULE", "RDATE", "EXRULE", "EXDATE", "DTSTART"] + + def populate(self, component: "Component", item: ContainerItem, context: ContextDict) -> bool: + assert isinstance(item, ContentLine) + self._check_component(component, context) + # self.lines.append(item) + return False + + def finalize(self, component: "Component", context: ContextDict): + self._check_component(component, context) + # rrulestr("\r\n".join(self.lines), tzinfos={}, compatible=True) + + def serialize(self, component: "Component", output: Container, context: ContextDict): + pass + # value = rruleset() + # for rrule in value._rrule: + # output.append(ContentLine("RRULE", value=re.match("^RRULE:(.*)$", str(rrule)).group(1))) + # for exrule in value._exrule: + # output.append(ContentLine("EXRULE", value=re.match("^RRULE:(.*)$", str(exrule)).group(1))) + # for rdate in value._rdate: + # output.append(ContentLine(name="RDATE", value=DatetimeConverter.INST.serialize(rdate))) + # for exdate in value._exdate: + # output.append(ContentLine(name="EXDATE", value=DatetimeConverter.INST.serialize(exdate))) + + +AttributeConverter.BY_TYPE[rruleset] = RecurrenceConverter + + +class PersonConverter(AttributeConverter): + # TODO handle lists + + @property + def filter_ics_names(self) -> List[str]: + return [] + + def populate(self, component: "Component", item: ContainerItem, context: ContextDict) -> bool: + assert isinstance(item, ContentLine) + self._check_component(component, context) + return False + + def serialize(self, component: "Component", output: Container, context: ContextDict): + pass + + +AttributeConverter.BY_TYPE[Person] = PersonConverter +AttributeConverter.BY_TYPE[Attendee] = PersonConverter +AttributeConverter.BY_TYPE[Organizer] = PersonConverter + + +class AlarmConverter(ComponentConverter): + def populate(self, component: "Component", item: ContainerItem, context: ContextDict) -> bool: + # TODO handle trigger: Union[timedelta, datetime, None] before duration + assert isinstance(item, Container) + self._check_component(component, context) + + from ics.alarm import get_type_from_action + alarm_type = get_type_from_action(item) + instance = alarm_type() + alarm_type.Meta.populate_instance(instance, item, context) + self.set_or_append_value(component, instance) + return True diff --git a/src/ics/converter/timespan.py b/src/ics/converter/timespan.py new file mode 100644 index 00000000..74592ed8 --- /dev/null +++ b/src/ics/converter/timespan.py @@ -0,0 +1,125 @@ +from typing import List, TYPE_CHECKING, cast + +from ics.converter.base import AttributeConverter +from ics.grammar import Container, ContentLine +from ics.timespan import EventTimespan, Timespan, TodoTimespan +from ics.types import ContainerItem, ContextDict, ExtraParams, copy_extra_params +from ics.utils import ensure_datetime +from ics.valuetype.datetime import DateConverter, DatetimeConverter, DurationConverter + +if TYPE_CHECKING: + from ics.component import Component + +CONTEXT_BEGIN_TIME = "timespan_begin_time" +CONTEXT_END_TIME = "timespan_end_time" +CONTEXT_DURATION = "timespan_duration" +CONTEXT_PRECISION = "timespan_precision" +CONTEXT_END_NAME = "timespan_end_name" +CONTEXT_ITEMS = "timespan_items" +CONTEXT_KEYS = [CONTEXT_BEGIN_TIME, CONTEXT_END_TIME, CONTEXT_DURATION, + CONTEXT_PRECISION, CONTEXT_END_NAME, CONTEXT_ITEMS] + + +class TimespanConverter(AttributeConverter): + @property + def default_priority(self) -> int: + return 10000 + + @property + def filter_ics_names(self) -> List[str]: + return ["DTSTART", "DTEND", "DUE", "DURATION"] + + def populate(self, component: "Component", item: ContainerItem, context: ContextDict) -> bool: + assert isinstance(item, ContentLine) + self._check_component(component, context) + + seen_items = context.setdefault(CONTEXT_ITEMS, set()) + if item.name in seen_items: + raise ValueError("duplicate value for %s in %s" % (item.name, item)) + seen_items.add(item.name) + + params = copy_extra_params(item.params) + if item.name in ["DTSTART", "DTEND", "DUE"]: + value_type = params.pop("VALUE", ["DATE-TIME"]) + if value_type == ["DATE-TIME"]: + precision = "second" + elif value_type == ["DATE"]: + precision = "day" + else: + raise ValueError("can't handle %s with value type %s" % (item.name, value_type)) + + if context[CONTEXT_PRECISION] is None: + context[CONTEXT_PRECISION] = precision + else: + if context[CONTEXT_PRECISION] != precision: + raise ValueError("event with diverging begin and end time precision") + + if precision == "day": + value = DateConverter.INST.parse(item.value, params, context) + else: + assert precision == "second" + value = DatetimeConverter.INST.parse(item.value, params, context) + + if item.name == "DTSTART": + self.set_or_append_extra_params(component, params, name="begin") + context[CONTEXT_BEGIN_TIME] = value + else: + end_name = {"DTEND": "end", "DUE": "due"}[item.name] + context[CONTEXT_END_NAME] = end_name + self.set_or_append_extra_params(component, params, name=end_name) + context[CONTEXT_END_TIME] = value + + else: + assert item.name == "DURATION" + self.set_or_append_extra_params(component, params, name="duration") + context[CONTEXT_DURATION] = DurationConverter.INST.parse(item.value, params, context) + + return True + + def finalize(self, component: "Component", context: ContextDict): + self._check_component(component, context) + # missing values will be reported by the Timespan validator + timespan = self.value_type( + ensure_datetime(context[CONTEXT_BEGIN_TIME]), ensure_datetime(context[CONTEXT_END_TIME]), + context[CONTEXT_DURATION], context[CONTEXT_PRECISION]) + if context[CONTEXT_END_NAME] and context[CONTEXT_END_NAME] != timespan._end_name(): + raise ValueError("expected to get %s value, but got %s instead" + % (timespan._end_name(), context[CONTEXT_END_NAME])) + self.set_or_append_value(component, timespan) + super(TimespanConverter, self).finalize(component, context) + # we need to clear all values, otherwise they might not get overwritten by the next parsed Timespan + for key in CONTEXT_KEYS: + context.pop(key, None) + + def serialize(self, component: "Component", output: Container, context: ContextDict): + self._check_component(component, context) + value: Timespan = self.get_value(component) + if value.is_all_day(): + value_type = {"VALUE": ["DATE"]} + dt_conv = DateConverter.INST + else: + value_type = {} # implicit default is {"VALUE": ["DATE-TIME"]} + dt_conv = DatetimeConverter.INST + + if value.get_begin(): + params = copy_extra_params(cast(ExtraParams, self.get_extra_params(component, "begin"))) + params.update(value_type) + dt_value = dt_conv.serialize(value.get_begin(), params, context) + output.append(ContentLine(name="DTSTART", params=params, value=dt_value)) + + if value.get_end_representation() == "end": + end_name = {"end": "DTEND", "due": "DUE"}[value._end_name()] + params = copy_extra_params(cast(ExtraParams, self.get_extra_params(component, end_name))) + params.update(value_type) + dt_value = dt_conv.serialize(value.get_effective_end(), params, context) + output.append(ContentLine(name=end_name, params=params, value=dt_value)) + + elif value.get_end_representation() == "duration": + params = copy_extra_params(cast(ExtraParams, self.get_extra_params(component, "duration"))) + dur_value = DurationConverter.INST.serialize(value.get_effective_duration(), params, context) + output.append(ContentLine(name="DURATION", params=params, value=dur_value)) + + +AttributeConverter.BY_TYPE[Timespan] = TimespanConverter +AttributeConverter.BY_TYPE[EventTimespan] = TimespanConverter +AttributeConverter.BY_TYPE[TodoTimespan] = TimespanConverter diff --git a/src/ics/converter/value.py b/src/ics/converter/value.py new file mode 100644 index 00000000..0deba572 --- /dev/null +++ b/src/ics/converter/value.py @@ -0,0 +1,139 @@ +from typing import Any, List, TYPE_CHECKING, Tuple, cast + +import attr + +from ics.converter.base import AttributeConverter +from ics.grammar import Container, ContentLine +from ics.types import ContainerItem, ContextDict, ExtraParams, copy_extra_params +from ics.valuetype.base import ValueConverter + +if TYPE_CHECKING: + from ics.component import Component + + +@attr.s(frozen=True) +class AttributeValueConverter(AttributeConverter): + value_converters: List[ValueConverter] + + def __attrs_post_init__(self): + super(AttributeValueConverter, self).__attrs_post_init__() + object.__setattr__(self, "value_converters", []) + for value_type in self.value_types: + converter = ValueConverter.BY_TYPE.get(value_type, None) + if converter is None: + raise ValueError("can't convert %s with ValueConverter" % value_type) + self.value_converters.append(converter) + + @property + def filter_ics_names(self) -> List[str]: + return [self.ics_name] + + @property + def ics_name(self) -> str: + name = self.attribute.metadata.get("ics_name", None) + if not name: + name = self.attribute.name.upper().replace("_", "-").strip("-") + return name + + def __prepare_params(self, line: "ContentLine") -> Tuple[ExtraParams, ValueConverter]: + params = copy_extra_params(line.params) + value_type = params.pop("VALUE", None) + if value_type: + if len(value_type) != 1: + raise ValueError("multiple VALUE type definitions in %s" % line) + for converter in self.value_converters: + if converter.ics_type == value_type[0]: + break + else: + raise ValueError("can't convert %s with %s" % (line, self)) + else: + converter = self.value_converters[0] + return params, converter + + # TODO make storing/writing extra values/params configurably optional, but warn when information is lost + + def populate(self, component: "Component", item: ContainerItem, context: ContextDict) -> bool: + assert isinstance(item, ContentLine) + self._check_component(component, context) + if self.is_multi_value: + params, converter = self.__prepare_params(item) + for value in converter.split_value_list(item.value): + context[(self, "current_value_count")] += 1 + params = copy_extra_params(params) + parsed = converter.parse(value, params, context) # might modify params and context + params["__merge_next"] = True # type: ignore + self.set_or_append_extra_params(component, params) + self.set_or_append_value(component, parsed) + if params is not None: + params["__merge_next"] = False # type: ignore + else: + if context[(self, "current_value_count")] > 0: + raise ValueError("attribute %s can only be set once, second occurrence is %s" % (self.ics_name, item)) + context[(self, "current_value_count")] += 1 + params, converter = self.__prepare_params(item) + parsed = converter.parse(item.value, params, context) # might modify params and context + self.set_or_append_extra_params(component, params) + self.set_or_append_value(component, parsed) + return True + + def finalize(self, component: "Component", context: ContextDict): + self._check_component(component, context) + if self.is_required and context[(self, "current_value_count")] < 1: + raise ValueError("attribute %s is required but got no value" % self.ics_name) + super(AttributeValueConverter, self).finalize(component, context) + + def __find_value_converter(self, params: ExtraParams, value: Any) -> ValueConverter: + for nr, converter in enumerate(self.value_converters): + if not isinstance(value, converter.python_type): continue + if nr > 0: + params["VALUE"] = [converter.ics_type] + return converter + else: + raise ValueError("can't convert %s with %s" % (value, self)) + + def serialize(self, component: "Component", output: Container, context: ContextDict): + if self.is_multi_value: + self.__serialize_multi(component, output, context) + else: + value = self.get_value(component) + if value: + params = copy_extra_params(cast(ExtraParams, self.get_extra_params(component))) + converter = self.__find_value_converter(params, value) + serialized = converter.serialize(value, params, context) + output.append(ContentLine(name=self.ics_name, params=params, value=serialized)) + + def __serialize_multi(self, component: "Component", output: "Container", context: ContextDict): + extra_params = cast(List[ExtraParams], self.get_extra_params(component)) + values = self.get_value_list(component) + if len(extra_params) != len(values): + raise ValueError("length of extra params doesn't match length of parameters" + " for attribute %s of %r" % (self.attribute.name, component)) + + merge_next = False + current_params = None + current_values = [] + + for value, params in zip(values, extra_params): + merge_next = False + params = copy_extra_params(params) + if params.pop("__merge_next", False): # type: ignore + merge_next = True + converter = self.__find_value_converter(params, value) + serialized = converter.serialize(value, params, context) # might modify params and context + + if current_params is not None: + if current_params != params: + raise ValueError() + else: + current_params = params + + current_values.append(serialized) + + if not merge_next: + cl = ContentLine(name=self.ics_name, params=params, value=converter.join_value_list(current_values)) + output.append(cl) + current_params = None + current_values = [] + + if merge_next: + raise ValueError("last value in value list may not have merge_next set") diff --git a/src/ics/event.py b/src/ics/event.py new file mode 100644 index 00000000..2ee9f8c2 --- /dev/null +++ b/src/ics/event.py @@ -0,0 +1,257 @@ +from datetime import datetime, timedelta +from typing import Any, List, Optional, Tuple, Union + +import attr +from attr.converters import optional as c_optional +from attr.validators import in_, instance_of, optional as v_optional + +from ics.alarm import BaseAlarm +from ics.attendee import Attendee, Organizer +from ics.component import Component +from ics.converter.base import ics_attr_meta +from ics.converter.component import ComponentMeta +from ics.converter.timespan import TimespanConverter +from ics.geo import Geo, make_geo +from ics.timespan import EventTimespan, Timespan +from ics.types import DatetimeLike, EventOrTimespan, EventOrTimespanOrInstant, TimedeltaLike, URL, get_timespan_if_calendar_entry +from ics.utils import check_is_instance, ensure_datetime, ensure_timedelta, ensure_utc, now_in_utc, uid_gen, validate_not_none + +STATUS_VALUES = (None, 'TENTATIVE', 'CONFIRMED', 'CANCELLED') + + +@attr.s(eq=True, order=False) +class CalendarEntryAttrs(Component): + _timespan: Timespan = attr.ib(validator=instance_of(Timespan), metadata=ics_attr_meta(converter=TimespanConverter)) + summary: Optional[str] = attr.ib(default=None) + uid: str = attr.ib(factory=uid_gen) + + description: Optional[str] = attr.ib(default=None) + location: Optional[str] = attr.ib(default=None) + url: Optional[str] = attr.ib(default=None) + status: Optional[str] = attr.ib(default=None, converter=c_optional(str.upper), validator=v_optional(in_(STATUS_VALUES))) # type: ignore + + created: Optional[datetime] = attr.ib(default=None, converter=ensure_utc) # type: ignore + last_modified: Optional[datetime] = attr.ib(default=None, converter=ensure_utc) # type: ignore + dtstamp: datetime = attr.ib(factory=now_in_utc, converter=ensure_utc, validator=validate_not_none) # type: ignore + + alarms: List[BaseAlarm] = attr.ib(factory=list, converter=list) + attach: List[Union[URL, bytes]] = attr.ib(factory=list, converter=list) + + def __init_subclass__(cls): + super().__init_subclass__() + for cmp in ("__lt__", "__gt__", "__le__", "__ge__"): + child_cmp, parent_cmp = getattr(cls, cmp), getattr(CalendarEntryAttrs, cmp) + if child_cmp != parent_cmp: + raise TypeError("%s may not overwrite %s" % (child_cmp, parent_cmp)) + + #################################################################################################################### + + @property + def begin(self) -> Optional[datetime]: + """Get or set the beginning of the event. + + | Will return a :class:`datetime` object. + | May be set to anything that :func:`datetime.__init__` understands. + | If an end is defined (not a duration), .begin must not + be set to a superior value. + | For all-day events, the time is truncated to midnight when set. + """ + return self._timespan.get_begin() + + @begin.setter + def begin(self, value: DatetimeLike): + self._timespan = self._timespan.replace(begin_time=ensure_datetime(value)) + + @property + def end(self) -> Optional[datetime]: + """Get or set the end of the event. + + | Will return a :class:`datetime` object. + | May be set to anything that :func:`datetime.__init__` understands. + | If set to a non null value, removes any already + existing duration. + | Setting to None will have unexpected behavior if + begin is not None. + | Must not be set to an inferior value than self.begin. + | When setting end time for for all-day events, if the end time + is midnight, that day is not included. Otherwise, the end is + rounded up to midnight the next day, including the full day. + Note that rounding is different from :func:`make_all_day`. + """ + return self._timespan.get_effective_end() + + @end.setter + def end(self, value: DatetimeLike): + self._timespan = self._timespan.replace(end_time=ensure_datetime(value), duration=None) + + @property + def duration(self) -> Optional[timedelta]: + """Get or set the duration of the event. + + | Will return a timedelta object. + | May be set to anything that timedelta() understands. + | May be set with a dict ({"days":2, "hours":6}). + | If set to a non null value, removes any already + existing end time. + | Duration of an all-day event is rounded up to a full day. + """ + return self._timespan.get_effective_duration() + + @duration.setter + def duration(self, value: timedelta): + self._timespan = self._timespan.replace(duration=ensure_timedelta(value), end_time=None) + + def convert_end(self, representation): + self._timespan = self._timespan.convert_end(representation) + + @property + def end_representation(self) -> Optional[str]: + return self._timespan.get_end_representation() + + @property + def has_explicit_end(self) -> bool: + return self._timespan.has_explicit_end() + + @property + def all_day(self) -> bool: + return self._timespan.is_all_day() + + def make_all_day(self): + """Transforms self to an all-day event or a time-based event. + + | The event will span all the days from the begin to *and including* + the end day. For example, assume begin = 2018-01-01 10:37, + end = 2018-01-02 14:44. After make_all_day, begin = 2018-01-01 + [00:00], end = 2018-01-03 [00:00], and duration = 2 days. + | If duration is used instead of the end time, it is rounded up to an + even day. 2 days remains 2 days, but 2 days and one second becomes 3 days. + | If neither duration not end are set, a duration of one day is implied. + | If self is already all-day, it is unchanged. + """ + self._timespan = self._timespan.make_all_day() + + def unset_all_day(self): + self._timespan = self._timespan.replace(precision="seconds") + + @property + def floating(self) -> bool: + return self._timespan.is_floating() + + def replace_timezone(self, tzinfo): + self._timespan = self._timespan.replace_timezone(tzinfo) + + def convert_timezone(self, tzinfo): + self._timespan = self._timespan.convert_timezone(tzinfo) + + @property + def timespan(self) -> Timespan: + return self._timespan + + def __str__(self) -> str: + name = [self.__class__.__name__] + if self.summary: + name.append("'%s'" % self.summary) + prefix, _, suffix = self._timespan.get_str_segments() + return "<%s>" % (" ".join(prefix + name + suffix)) + + #################################################################################################################### + + def cmp_tuple(self) -> Tuple[datetime, datetime, str]: + return (*self.timespan.cmp_tuple(), self.summary or "") + + def __lt__(self, other: Any) -> bool: + """self < other""" + if isinstance(other, CalendarEntryAttrs): + return self.cmp_tuple() < other.cmp_tuple() + else: + return NotImplemented + + def __gt__(self, other: Any) -> bool: + """self > other""" + if isinstance(other, CalendarEntryAttrs): + return self.cmp_tuple() > other.cmp_tuple() + else: + return NotImplemented + + def __le__(self, other: Any) -> bool: + """self <= other""" + if isinstance(other, CalendarEntryAttrs): + return self.cmp_tuple() <= other.cmp_tuple() + else: + return NotImplemented + + def __ge__(self, other: Any) -> bool: + """self >= other""" + if isinstance(other, CalendarEntryAttrs): + return self.cmp_tuple() >= other.cmp_tuple() + else: + return NotImplemented + + def starts_within(self, second: EventOrTimespan) -> bool: + return self._timespan.starts_within(get_timespan_if_calendar_entry(second)) + + def ends_within(self, second: EventOrTimespan) -> bool: + return self._timespan.ends_within(get_timespan_if_calendar_entry(second)) + + def intersects(self, second: EventOrTimespan) -> bool: + return self._timespan.intersects(get_timespan_if_calendar_entry(second)) + + def includes(self, second: EventOrTimespanOrInstant) -> bool: + return self._timespan.includes(get_timespan_if_calendar_entry(second)) + + def is_included_in(self, second: EventOrTimespan) -> bool: + return self._timespan.is_included_in(get_timespan_if_calendar_entry(second)) + + +@attr.s(eq=True, order=False) # order methods are provided by CalendarEntryAttrs +class EventAttrs(CalendarEntryAttrs): + classification: Optional[str] = attr.ib(default=None, validator=v_optional(instance_of(str))) + + transparent: Optional[bool] = attr.ib(default=None) + organizer: Optional[Organizer] = attr.ib(default=None, validator=v_optional(instance_of(Organizer))) + geo: Optional[Geo] = attr.ib(default=None, converter=make_geo) # type: ignore + + attendees: List[Attendee] = attr.ib(factory=list, converter=list) + categories: List[str] = attr.ib(factory=list, converter=list) + + def add_attendee(self, attendee: Attendee): + """ Add an attendee to the attendees set """ + check_is_instance("attendee", attendee, Attendee) + self.attendees.append(attendee) + + +class Event(EventAttrs): + """A calendar event. + + Can be full-day or between two instants. + Can be defined by a beginning instant and + a duration *or* end instant. + + Unsupported event attributes can be found in `event.extra`, + a :class:`ics.parse.Container`. You may add some by appending a + :class:`ics.parse.ContentLine` to `.extra` + """ + + _timespan: EventTimespan = attr.ib(validator=instance_of(EventTimespan)) + + Meta = ComponentMeta("VEVENT") + + def __init__( + self, + summary: str = None, + begin: DatetimeLike = None, + end: DatetimeLike = None, + duration: TimedeltaLike = None, + *args, **kwargs + ): + """Initializes a new :class:`ics.event.Event`. + + Raises: + ValueError: if `timespan` and any of `begin`, `end` or `duration` + are specified at the same time, + or if validation of the timespan fails (see :method:`ics.timespan.Timespan.validate`). + """ + if (begin is not None or end is not None or duration is not None) and "timespan" in kwargs: + raise ValueError("can't specify explicit timespan together with any of begin, end or duration") + kwargs.setdefault("timespan", EventTimespan(ensure_datetime(begin), ensure_datetime(end), ensure_timedelta(duration))) + super(Event, self).__init__(kwargs.pop("timespan"), summary, *args, **kwargs) diff --git a/src/ics/geo.py b/src/ics/geo.py new file mode 100644 index 00000000..64040fce --- /dev/null +++ b/src/ics/geo.py @@ -0,0 +1,26 @@ +from typing import Dict, NamedTuple, Tuple, Union, overload + + +class Geo(NamedTuple): + latitude: float + longitude: float + # TODO also store params like comment? + + +@overload +def make_geo(value: None) -> None: + ... + + +@overload +def make_geo(value: Union[Dict[str, float], Tuple[float, float]]) -> "Geo": + ... + + +def make_geo(value): + if isinstance(value, dict): + return Geo(**value) + elif isinstance(value, tuple): + return Geo(*value) + else: + return None diff --git a/src/ics/grammar/__init__.py b/src/ics/grammar/__init__.py new file mode 100644 index 00000000..d081cfe0 --- /dev/null +++ b/src/ics/grammar/__init__.py @@ -0,0 +1,296 @@ +import functools +import re +import warnings +from collections import UserString +from typing import Generator, List, MutableSequence, Union + +import attr +import importlib_resources # type: ignore +import tatsu # type: ignore +from tatsu.exceptions import FailedToken # type: ignore + +from ics.types import ContainerItem, ExtraParams, RuntimeAttrValidation, copy_extra_params +from ics.utils import limit_str_length, next_after_str_escape, validate_truthy + +__all__ = ["ParseError", "QuotedParamValue", "ContentLine", "Container", "string_to_container"] + +GRAMMAR = tatsu.compile(importlib_resources.read_text(__name__, "contentline.ebnf")) + + +class ParseError(Exception): + pass + + +class QuotedParamValue(UserString): + pass + + +@attr.s +class ContentLine(RuntimeAttrValidation): + """ + Represents one property line. + + For example: + + ``FOO;BAR=1:YOLO`` is represented by + + ``ContentLine('FOO', {'BAR': ['1']}, 'YOLO'))`` + """ + + name: str = attr.ib(converter=str.upper) # type: ignore + params: ExtraParams = attr.ib(factory=lambda: ExtraParams(dict())) + value: str = attr.ib(default="") + + # TODO store value type for jCal and line number for error messages + + def serialize(self): + return "".join(self.serialize_iter()) + + def serialize_iter(self, newline=False): + yield self.name + for pname in self.params: + yield ";" + yield pname + yield "=" + for nr, pval in enumerate(self.params[pname]): + if nr > 0: + yield "," + if isinstance(pval, QuotedParamValue) or re.search("[:;,]", pval): + # Property parameter values that contain the COLON, SEMICOLON, or COMMA character separators + # MUST be specified as quoted-string text values. + # TODO The DQUOTE character is used as a delimiter for parameter values that contain + # restricted characters or URI text. + # TODO Property parameter values that are not in quoted-strings are case-insensitive. + yield '"%s"' % escape_param(pval) + else: + yield escape_param(pval) + yield ":" + yield self.value + if newline: + yield "\r\n" + + def __getitem__(self, item): + return self.params[item] + + def __setitem__(self, item, values): + self.params[item] = list(values) + + @classmethod + def parse(cls, line): + """Parse a single iCalendar-formatted line into a ContentLine""" + if "\n" in line or "\r" in line: + raise ValueError("ContentLine can only contain escaped newlines") + try: + ast = GRAMMAR.parse(line) + except FailedToken: + raise ParseError() + else: + return cls.interpret_ast(ast) + + @classmethod + def interpret_ast(cls, ast): + name = ast['name'] + value = ast['value'] + params = ExtraParams(dict()) + for param_ast in ast.get('params', []): + param_name = param_ast["name"] + params[param_name] = [] + for param_value_ast in param_ast["values_"]: + val = unescape_param(param_value_ast["value"]) + if param_value_ast["quoted"] == "true": + val = QuotedParamValue(val) + params[param_name].append(val) + return cls(name, params, value) + + def clone(self): + """Makes a copy of itself""" + return attr.evolve(self, params=copy_extra_params(self.params)) + + def __str__(self): + return "%s%s='%s'" % (self.name, self.params or "", limit_str_length(self.value)) + + +def _wrap_list_func(list_func): + @functools.wraps(list_func) + def wrapper(self, *args, **kwargs): + return list_func(self.data, *args, **kwargs) + + return wrapper + + +@attr.s(repr=False) +class Container(MutableSequence[ContainerItem]): + """Represents an iCalendar object. + Contains a list of ContentLines or Containers. + + Args: + + name: the name of the object (VCALENDAR, VEVENT etc.) + items: Containers or ContentLines + """ + + name: str = attr.ib(converter=str.upper, validator=validate_truthy) # type:ignore + data: List[ContainerItem] = attr.ib(converter=list, default=[], + validator=lambda inst, attr, value: inst.check_items(*value)) + + def __str__(self): + return "%s[%s]" % (self.name, ", ".join(str(cl) for cl in self.data)) + + def __repr__(self): + return "%s(%r, %s)" % (type(self).__name__, self.name, repr(self.data)) + + def serialize(self): + return "".join(self.serialize_iter()) + + def serialize_iter(self, newline=False): + yield "BEGIN:" + yield self.name + yield "\r\n" + for line in self: + yield from line.serialize_iter(newline=True) + yield "END:" + yield self.name + if newline: + yield "\r\n" + + @classmethod + def parse(cls, name, tokenized_lines): + items = [] + if not name.isupper(): + warnings.warn("Container 'BEGIN:%s' is not all-uppercase" % name) + for line in tokenized_lines: + if line.name == 'BEGIN': + items.append(cls.parse(line.value, tokenized_lines)) + elif line.name == 'END': + if line.value.upper() != name.upper(): + raise ParseError( + "Expected END:{}, got END:{}".format(name, line.value)) + if not name.isupper(): + warnings.warn("Container 'END:%s' is not all-uppercase" % name) + break + else: + items.append(line) + else: # if break was not called + raise ParseError("Missing END:{}".format(name)) + return cls(name, items) + + def clone(self, items=None, deep=False): + """Makes a copy of itself""" + if items is None: + items = self.data + if deep: + items = (item.clone() for item in items) + return attr.evolve(self, data=items) + + @staticmethod + def check_items(*items): + from ics.utils import check_is_instance + if len(items) == 1: + check_is_instance("item", items[0], (ContentLine, Container)) + else: + for nr, item in enumerate(items): + check_is_instance("item %s" % nr, item, (ContentLine, Container)) + + def __setitem__(self, index, value): # index might be slice and value might be iterable + self.data.__setitem__(index, value) + attr.validate(self) + + def insert(self, index, value): + self.check_items(value) + self.data.insert(index, value) + + def append(self, value): + self.check_items(value) + self.data.append(value) + + def extend(self, values): + self.data.extend(values) + attr.validate(self) + + def __getitem__(self, i): + if isinstance(i, slice): + return attr.evolve(self, data=self.data[i]) + else: + return self.data[i] + + __contains__ = _wrap_list_func(list.__contains__) + __delitem__ = _wrap_list_func(list.__delitem__) + __iter__ = _wrap_list_func(list.__iter__) + __len__ = _wrap_list_func(list.__len__) + __reversed__ = _wrap_list_func(list.__reversed__) + clear = _wrap_list_func(list.clear) + count = _wrap_list_func(list.count) + index = _wrap_list_func(list.index) + pop = _wrap_list_func(list.pop) + remove = _wrap_list_func(list.remove) + reverse = _wrap_list_func(list.reverse) + + +def escape_param(string: Union[str, QuotedParamValue]) -> str: + return str(string).translate( + {ord("\""): "^'", + ord("^"): "^^", + ord("\n"): "^n", + ord("\r"): ""}) + + +def unescape_param(string: str) -> str: + return "".join(unescape_param_iter(string)) + + +def unescape_param_iter(string: str) -> Generator[str, None, None]: + it = iter(string) + for c1 in it: + if c1 == "^": + c2 = next_after_str_escape(it, full_str=string) + if c2 == "n": + yield "\n" + elif c2 == "^": + yield "^" + elif c2 == "'": + yield "\"" + else: + yield c1 + yield c2 + else: + yield c1 + + +def unfold_lines(physical_lines): + current_line = '' + for line in physical_lines: + line = line.rstrip('\r') + if not current_line: + current_line = line + elif line[0] in (' ', '\t'): + current_line += line[1:] + else: + yield current_line + current_line = line + if current_line: + yield current_line + + +def tokenize_line(unfolded_lines): + for line in unfolded_lines: + yield ContentLine.parse(line) + + +def parse(tokenized_lines): + # tokenized_lines must be an iterator, so that Container.parse can consume/steal lines + tokenized_lines = iter(tokenized_lines) + res = [] + for line in tokenized_lines: + if line.name == 'BEGIN': + res.append(Container.parse(line.value, tokenized_lines)) + else: + res.append(line) + return res + + +def lines_to_container(lines): + return parse(tokenize_line(unfold_lines(lines))) + + +def string_to_container(txt): + return lines_to_container(txt.splitlines()) diff --git a/src/ics/grammar/contentline.ebnf b/src/ics/grammar/contentline.ebnf new file mode 100644 index 00000000..5a5224ff --- /dev/null +++ b/src/ics/grammar/contentline.ebnf @@ -0,0 +1,37 @@ +@@grammar::contentline +@@whitespace :: None + +start = contentline $ ; + +ALPHADIGIT = ? "[a-zA-Z0-9]"; +ALPHADIGIT_3_OR_MORE = ? "[a-zA-Z0-9]{3,}"; +ALPHADIGIT_MINUS = ? "[a-zA-Z0-9\-]"; +ALPHADIGIT_MINUS_PLUS = ? "[a-zA-Z0-9\-]+"; +WSP = " "; + +DQUOTE = '"' ; + +QSAFE_CHAR = ?"[^\x00-\x08\x0A-\x1F\x22\x7F]"; +QSAFE_CHAR_STAR = ?"[^\x00-\x08\x0A-\x1F\x22\x7F]*"; + +SAFE_CHAR = ?"[^\x00-\x08\x0A-\x1F\x22\x2C\x3A\x3B\x7F]"; +SAFE_CHAR_STAR = ?"[^\x00-\x08\x0A-\x1F\x22\x2C\x3A\x3B\x7F]*"; + +VALUE_CHAR = ?"[^\x00-\x08\x0A-\x1F\x7F]"; +VALUE_CHAR_STAR = ?"[^\x00-\x08\x0A-\x1F\x7F]*"; + + +name = iana_token | x_name ; +iana_token = ALPHADIGIT_MINUS_PLUS ; +x_name = "X-" [vendorid "-"] ALPHADIGIT_MINUS_PLUS ; +vendorid = ALPHADIGIT_3_OR_MORE ; + +contentline = name:name {(";" params+:param )}* ":" value:value ; + +param = name:param_name "=" values+:param_value {("," values+:param_value)}* ; +param_name = iana_token | x_name ; +param_value = value:quoted_string quoted:`true` | value:paramtext quoted:`false` ; + +paramtext = SAFE_CHAR_STAR ; +value = VALUE_CHAR_STAR ; +quoted_string = DQUOTE @:QSAFE_CHAR_STAR DQUOTE ; diff --git a/src/ics/icalendar.py b/src/ics/icalendar.py new file mode 100644 index 00000000..31ab4df4 --- /dev/null +++ b/src/ics/icalendar.py @@ -0,0 +1,113 @@ +from datetime import tzinfo +from typing import ClassVar, Iterable, Iterator, List, Optional, Union + +import attr +from attr.validators import instance_of + +from ics.component import Component +from ics.converter.component import ComponentMeta +from ics.event import Event +from ics.grammar import Container, string_to_container +from ics.timeline import Timeline +from ics.todo import Todo + + +@attr.s +class CalendarAttrs(Component): + version: str = attr.ib(validator=instance_of(str)) # default set by Calendar.Meta.DEFAULT_VERSION + prodid: str = attr.ib(validator=instance_of(str)) # default set by Calendar.Meta.DEFAULT_PRODID + scale: Optional[str] = attr.ib(default=None) + method: Optional[str] = attr.ib(default=None) + + _timezones: List[tzinfo] = attr.ib(factory=list, converter=list) # , init=False, repr=False, eq=False, order=False, hash=False) + events: List[Event] = attr.ib(factory=list, converter=list) + todos: List[Todo] = attr.ib(factory=list, converter=list) + + +class Calendar(CalendarAttrs): + """ + Represents an unique RFC 5545 iCalendar. + + Attributes: + + events: a list of `Event`s contained in the Calendar + todos: a list of `Todo`s contained in the Calendar + timeline: a `Timeline` instance for iterating this Calendar in chronological order + + """ + + Meta = ComponentMeta("VCALENDAR") + DEFAULT_VERSION: ClassVar[str] = "2.0" + DEFAULT_PRODID: ClassVar[str] = "ics.py - http://git.io/lLljaA" + + def __init__( + self, + imports: Union[str, Container, None] = None, + events: Optional[Iterable[Event]] = None, + todos: Optional[Iterable[Todo]] = None, + creator: str = None, + **kwargs + ): + """Initializes a new Calendar. + + Args: + imports (**str**): data to be imported into the Calendar, + events (**Iterable[Event]**): `Event`s to be added to the calendar + todos (**Iterable[Todo]**): `Todo`s to be added to the calendar + creator (**string**): uid of the creator program. + """ + if events is None: + events = tuple() + if todos is None: + todos = tuple() + kwargs.setdefault("version", self.DEFAULT_VERSION) + kwargs.setdefault("prodid", creator if creator is not None else self.DEFAULT_PRODID) + super(Calendar, self).__init__(events=events, todos=todos, **kwargs) # type: ignore + self.timeline = Timeline(self, None) + + if imports is not None: + if isinstance(imports, Container): + self.populate(imports) + else: + containers = string_to_container(imports) + if len(containers) != 1: + raise ValueError("Multiple calendars in one file are not supported by this method." + "Use ics.Calendar.parse_multiple()") + self.populate(containers[0]) + + @property + def creator(self) -> str: + return self.prodid + + @creator.setter + def creator(self, value: str): + self.prodid = value + + @classmethod + def parse_multiple(cls, string): + """" + Parses an input string that may contain mutiple calendars + and retruns a list of :class:`ics.event.Calendar` + """ + containers = string_to_container(string) + return [cls(imports=c) for c in containers] + + def __str__(self) -> str: + return "".format( + len(self.events), + "s" if len(self.events) > 1 else "", + len(self.todos), + "s" if len(self.todos) > 1 else "") + + def __iter__(self) -> Iterator[str]: + """Returns: + iterable: an iterable version of __str__, line per line + (with line-endings). + + Example: + Can be used to write calendar to a file: + + >>> c = Calendar(); c.events.append(Event(summary="My cool event")) + >>> open('my.ics', 'w').writelines(c) + """ + return iter(self.serialize().splitlines(keepends=True)) diff --git a/src/ics/timeline.py b/src/ics/timeline.py new file mode 100644 index 00000000..e6ef0c84 --- /dev/null +++ b/src/ics/timeline.py @@ -0,0 +1,145 @@ +import heapq +from datetime import date, datetime, timedelta +from typing import Iterable, Iterator, Optional, TYPE_CHECKING, Tuple + +import attr + +from ics.event import Event +from ics.timespan import Normalization, Timespan +from ics.types import DatetimeLike, OptionalDatetimeLike, TimespanOrBegin +from ics.utils import ceil_datetime_to_midnight, ensure_datetime, floor_datetime_to_midnight + +if TYPE_CHECKING: + from ics.icalendar import Calendar + + +@attr.s +class Timeline(object): + """ + `Timeline`s allow iterating all event from a `Calendar` in chronological order, optionally also filtering events + according to their timestamps. + """ + + _calendar: "Calendar" = attr.ib() + _normalization: Optional[Normalization] = attr.ib() + + def __normalize_datetime(self, instant: DatetimeLike) -> datetime: + """ + Create a normalized datetime instance for the given instance. + """ + instant = ensure_datetime(instant) + if self._normalization: + instant = self._normalization.normalize(instant) + return instant + + def __normalize_timespan(self, start: TimespanOrBegin, stop: OptionalDatetimeLike = None) -> Timespan: + """ + Create a normalized timespan between `start` and `stop`. + Alternatively, this method can be called directly with a single timespan as parameter. + """ + if isinstance(start, Timespan): + if stop is not None: + raise ValueError("can't specify a Timespan and an additional stop time") + timespan = start + else: + timespan = Timespan(ensure_datetime(start), ensure_datetime(stop)) + if self._normalization: + timespan = self._normalization.normalize(timespan) + return timespan + + def iterator(self) -> Iterator[Tuple[Timespan, Event]]: + """ + Iterates on every event from the :class:`ics.icalendar.Calendar` in chronological order + + Note: + - chronological order is defined by the comparison operators in :class:`ics.timespan.Timespan` + - Events with no `begin` will not appear here. (To list all events in a `Calendar` use `Calendar.events`) + """ + # Using a heap is faster than sorting if the number of events (n) is + # much bigger than the number of events we extract from the iterator (k). + # Complexity: O(n + k log n). + heap: Iterable[Tuple[Timespan, Event]] = ( + (self.__normalize_timespan(e.timespan), e) + for e in self._calendar.events) + heap = [t for t in heap if t[0]] + heapq.heapify(heap) + while heap: + yield heapq.heappop(heap) + + def __iter__(self) -> Iterator[Event]: + """ + Iterates on every event from the :class:`ics.icalendar.Calendar` in chronological order + + Note: + - chronological order is defined by the comparison operators in :class:`ics.timespan.Timespan` + - Events with no `begin` will not appear here. (To list all events in a `Calendar` use `Calendar.events`) + """ + for _, e in self.iterator(): + yield e + + def included(self, start: TimespanOrBegin, stop: OptionalDatetimeLike = None) -> Iterator[Event]: + """ + Iterates (in chronological order) over every event that is included in the timespan between `start` and `stop`. + Alternatively, this method can be called directly with a single timespan as parameter. + """ + query = self.__normalize_timespan(start, stop) + for timespan, event in self.iterator(): + if timespan.is_included_in(query): + yield event + + def overlapping(self, start: TimespanOrBegin, stop: OptionalDatetimeLike = None) -> Iterator[Event]: + """ + Iterates (in chronological order) over every event that has an intersection with the timespan between `start` and `stop`. + Alternatively, this method can be called directly with a single timespan as parameter. + """ + query = self.__normalize_timespan(start, stop) + for timespan, event in self.iterator(): + if timespan.intersects(query): + yield event + + def start_after(self, instant: DatetimeLike) -> Iterator[Event]: + """ + Iterates (in chronological order) on every event from the :class:`ics.icalendar.Calendar` in chronological order. + The first event of the iteration has a starting date greater (later) than `instant`. + """ + instant = self.__normalize_datetime(instant) + for timespan, event in self.iterator(): + if timespan.begin_time is not None and timespan.begin_time > instant: + yield event + + def at(self, instant: DatetimeLike) -> Iterator[Event]: + """ + Iterates (in chronological order) over all events that are occuring during `instant`. + """ + instant = self.__normalize_datetime(instant) + for timespan, event in self.iterator(): + if timespan.includes(instant): + yield event + + def on(self, instant: DatetimeLike, strict: bool = False) -> Iterator[Event]: + """ + Iterates (in chronological order) over all events that occurs on `day`. + + :param strict: if True events will be returned only if they are strictly *included* in `day` + """ + begin = floor_datetime_to_midnight(ensure_datetime(instant)) + end = ceil_datetime_to_midnight(ensure_datetime(instant)) - timedelta(seconds=1) + query = self.__normalize_timespan(begin, end) + if strict: + return self.included(query) + else: + return self.overlapping(query) + + def today(self, strict: bool = False) -> Iterator[Event]: + """ + Iterates (in chronological order) over all events that occurs today. + + :param strict: if True events will be returned only if they are strictly *included* in `day` + """ + return self.on(date.today(), strict=strict) + + def now(self) -> Iterator[Event]: + """ + Iterates (in chronological order) over all events that occur right now. + """ + return self.at(datetime.utcnow()) diff --git a/src/ics/timespan.py b/src/ics/timespan.py new file mode 100644 index 00000000..a4a21778 --- /dev/null +++ b/src/ics/timespan.py @@ -0,0 +1,435 @@ +from datetime import datetime, timedelta, tzinfo as TZInfo +from typing import Any, NamedTuple, Optional, TypeVar, Union, cast, overload + +import attr +from attr.validators import instance_of, optional as v_optional +from dateutil.tz import tzlocal + +from ics.types import DatetimeLike +from ics.utils import TIMEDELTA_CACHE, TIMEDELTA_DAY, TIMEDELTA_ZERO, ceil_datetime_to_midnight, ensure_datetime, floor_datetime_to_midnight, timedelta_nearly_zero + + +@attr.s +class Normalization(object): + normalize_floating: bool = attr.ib() + normalize_with_tz: bool = attr.ib() + replacement: Union[TZInfo, None] = attr.ib() + + @overload + def normalize(self, value: "Timespan") -> "Timespan": + ... + + @overload # noqa + def normalize(self, value: DatetimeLike) -> datetime: + ... + + @overload # noqa + def normalize(self, value: None) -> None: + ... + + def normalize(self, value): # noqa + """ + Normalize datetime or timespan instances to make naive/floating ones (without timezone, i.e. tzinfo == None) + comparable to aware ones with a fixed timezone. + If None is selected as replacement, the timezone information will be stripped from aware datetimes. + If the replacement is set to any tzinfo instance, naive datetimes will be interpreted in that timezone. + """ + if value is None: + return None + elif not isinstance(value, Timespan): + value = ensure_datetime(value) + floating = (value.tzinfo is None) + replace_timezone = lambda value, tzinfo: value.replace(tzinfo=tzinfo) + else: + floating = value.is_floating() + replace_timezone = Timespan.replace_timezone + + normalize = (floating and self.normalize_floating) or (not floating and self.normalize_with_tz) + + if normalize: + return replace_timezone(value, self.replacement) + else: + return value + + +CMP_DATETIME_NONE_DEFAULT = datetime.min +CMP_NORMALIZATION = Normalization(normalize_floating=True, normalize_with_tz=False, replacement=tzlocal()) + +TimespanTuple = NamedTuple("TimespanTuple", [("begin", datetime), ("end", datetime)]) +NullableTimespanTuple = NamedTuple("NullableTimespanTuple", [("begin", Optional[datetime]), ("end", Optional[datetime])]) + +TimespanT = TypeVar('TimespanT', bound='Timespan') + + +@attr.s(slots=True, frozen=True, eq=True, order=False) +class Timespan(object): + begin_time: Optional[datetime] = attr.ib(validator=v_optional(instance_of(datetime)), default=None) + end_time: Optional[datetime] = attr.ib(validator=v_optional(instance_of(datetime)), default=None) + duration: Optional[timedelta] = attr.ib(validator=v_optional(instance_of(timedelta)), default=None) + precision: str = attr.ib(default="second") + + def _end_name(self) -> str: + return "end" + + def __attrs_post_init__(self): + self.validate() + + def replace( + self: TimespanT, + begin_time: Optional[datetime] = False, # type: ignore + end_time: Optional[datetime] = False, # type: ignore + duration: Optional[timedelta] = False, # type: ignore + precision: str = False # type: ignore + ) -> TimespanT: + if begin_time is False: + begin_time = self.begin_time + if end_time is False: + end_time = self.end_time + if duration is False: + duration = self.duration + if precision is False: + precision = self.precision + return type(self)(begin_time=begin_time, end_time=end_time, duration=duration, precision=precision) + + def replace_timezone(self: TimespanT, tzinfo: Optional[TZInfo]) -> TimespanT: + if self.is_all_day(): + raise ValueError("can't replace timezone of all-day event") + begin = self.get_begin() + if begin is not None: + begin = begin.replace(tzinfo=tzinfo) + if self.end_time is not None: + return self.replace(begin_time=begin, end_time=self.end_time.replace(tzinfo=tzinfo)) + else: + return self.replace(begin_time=begin) + + def convert_timezone(self: TimespanT, tzinfo: Optional[TZInfo]) -> TimespanT: + if self.is_all_day(): + raise ValueError("can't convert timezone of all-day timespan") + if self.is_floating(): + raise ValueError("can't convert timezone of timezone-naive floating timespan, use replace_timezone") + begin = self.get_begin() + if begin is not None: + begin = begin.astimezone(tzinfo) + if self.end_time is not None: + return self.replace(begin_time=begin, end_time=self.end_time.astimezone(tzinfo)) + else: + return self.replace(begin_time=begin) + + def validate(self): + def validate_timeprecision(value, name): + if self.precision == "day": + if floor_datetime_to_midnight(value) != value: + raise ValueError("%s time value %s has higher precision than set precision %s" % (name, value, self.precision)) + if value.tzinfo is not None: + raise ValueError("all-day timespan %s time %s can't have a timezone" % (name, value)) + + if self.begin_time is not None: + validate_timeprecision(self.begin_time, "begin") + + if self.end_time is not None: + validate_timeprecision(self.end_time, self._end_name()) + if self.begin_time > self.end_time: + raise ValueError("begin time must be before " + self._end_name() + " time") + if self.precision == "day" and self.end_time < (self.begin_time + TIMEDELTA_DAY): + raise ValueError("all-day timespan duration must be at least one day") + if self.duration is not None: + raise ValueError("can't set duration together with " + self._end_name() + " time") + if self.begin_time.tzinfo is None and self.end_time.tzinfo is not None: + raise ValueError(self._end_name() + " time may not have a timezone as the begin time doesn't either") + if self.begin_time.tzinfo is not None and self.end_time.tzinfo is None: + raise ValueError(self._end_name() + " time must have a timezone as the begin time also does") + duration = self.get_effective_duration() + if duration and not timedelta_nearly_zero(duration % TIMEDELTA_CACHE[self.precision]): + raise ValueError("effective duration value %s has higher precision than set precision %s" % + (self.get_effective_duration(), self.precision)) + + if self.duration is not None: + if self.duration < TIMEDELTA_ZERO: + raise ValueError("timespan duration must be positive") + if self.precision == "day" and self.duration < TIMEDELTA_DAY: + raise ValueError("all-day timespan duration must be at least one day") + if not timedelta_nearly_zero(self.duration % TIMEDELTA_CACHE[self.precision]): + raise ValueError("duration value %s has higher precision than set precision %s" % + (self.duration, self.precision)) + + else: + if self.end_time is not None: + # Todos might have end/due time without begin + validate_timeprecision(self.end_time, self._end_name()) + + if self.duration is not None: + raise ValueError("timespan without begin time can't have duration") + + def get_str_segments(self): + if self.is_all_day(): + prefix = ["all-day"] + elif self.is_floating(): + prefix = ["floating"] + else: + prefix = [] + + suffix = [] + + begin = self.begin_time + if begin is not None: + suffix.append("begin:") + if self.is_all_day(): + suffix.append(begin.strftime('%Y-%m-%d')) + else: + suffix.append(str(begin)) + + end = self.get_effective_end() + end_repr = self.get_end_representation() + if end is not None: + if end_repr == "end": + suffix.append("fixed") + suffix.append(self._end_name() + ":") + if self.is_all_day(): + suffix.append(end.strftime('%Y-%m-%d')) + else: + suffix.append(str(end)) + + duration = self.get_effective_duration() + if duration is not None and end_repr is not None: + if end_repr == "duration": + suffix.append("fixed") + suffix.append("duration:") + suffix.append(str(duration)) + + return prefix, [self.__class__.__name__], suffix + + def __str__(self) -> str: + prefix, name, suffix = self.get_str_segments() + return "<%s>" % (" ".join(prefix + name + suffix)) + + def __bool__(self): + return self.begin_time is not None or self.end_time is not None + + #################################################################################################################### + + def make_all_day(self) -> "Timespan": + if self.is_all_day(): + return self # Do nothing if we already are a all day timespan + + begin = self.begin_time + if begin is not None: + begin = floor_datetime_to_midnight(begin).replace(tzinfo=None) + + end = self.get_effective_end() + if end is not None: + end = ceil_datetime_to_midnight(end).replace(tzinfo=None) + if end == begin: # we also add another day if the duration would be 0 otherwise + end = end + TIMEDELTA_DAY + + if self.get_end_representation() == "duration": + assert end is not None + assert begin is not None + return self.replace(begin, None, end - begin, "day") + else: + return self.replace(begin, end, None, "day") + + def convert_end(self, target: Optional[str]) -> "Timespan": + current = self.get_end_representation() + current_is_end = current == "end" or current == self._end_name() + target_is_end = target == "end" or target == self._end_name() + if current == target or (current_is_end and target_is_end): + return self + elif current_is_end and target == "duration": + return self.replace(end_time=None, duration=self.get_effective_duration()) + elif current == "duration" and target_is_end: + return self.replace(end_time=self.get_effective_end(), duration=None) + elif target is None: + return self.replace(end_time=None, duration=None) + else: + raise ValueError("can't convert from representation %s to %s" % (current, target)) + + #################################################################################################################### + + def get_begin(self) -> Optional[datetime]: + return self.begin_time + + def get_effective_end(self) -> Optional[datetime]: + if self.end_time is not None: + return self.end_time + elif self.begin_time is not None: + duration = self.get_effective_duration() + if duration is not None: + return self.begin_time + duration + + return None + + def get_effective_duration(self) -> Optional[timedelta]: + if self.duration is not None: + return self.duration + elif self.end_time is not None and self.begin_time is not None: + return self.end_time - self.begin_time + else: + return None + + def get_precision(self) -> str: + return self.precision + + def is_all_day(self) -> bool: + return self.precision == "day" + + def is_floating(self) -> bool: + if self.begin_time is None: + if self.end_time is None: + return True + else: + return self.end_time.tzinfo is None + else: + return self.begin_time.tzinfo is None + + def get_end_representation(self) -> Optional[str]: + if self.duration is not None: + return "duration" + elif self.end_time is not None: + return "end" + else: + return None + + def has_explicit_end(self) -> bool: + return self.get_end_representation() is not None + + #################################################################################################################### + + @overload + def timespan_tuple(self, default: None = None, normalization: Normalization = None) -> NullableTimespanTuple: + ... + + @overload # noqa + def timespan_tuple(self, default: datetime, normalization: Normalization = None) -> TimespanTuple: + ... + + def timespan_tuple(self, default=None, normalization=None): # noqa + if normalization: + return TimespanTuple( + normalization.normalize(self.get_begin() or default), + normalization.normalize(self.get_effective_end() or default) + ) + else: + return TimespanTuple( + self.get_begin() or default, + self.get_effective_end() or default + ) + + def cmp_tuple(self) -> TimespanTuple: + return self.timespan_tuple(default=datetime.min, normalization=CMP_NORMALIZATION) + + def __require_tuple_components(self, values, *required): + for nr, (val, req) in enumerate(zip(values, required)): + if req and val is None: + event = "this event" if nr < 2 else "other event" + prop = "begin" if nr % 2 == 0 else "end" + raise ValueError("%s has no %s time" % (event, prop)) + + def starts_within(self, other: "Timespan") -> bool: + first = cast(TimespanTuple, self.timespan_tuple(normalization=CMP_NORMALIZATION)) + second = cast(TimespanTuple, other.timespan_tuple(normalization=CMP_NORMALIZATION)) + self.__require_tuple_components(first + second, True, False, True, True) + + # the timespan doesn't include its end instant / day + return second.begin <= first.begin < second.end + + def ends_within(self, other: "Timespan") -> bool: + first = cast(TimespanTuple, self.timespan_tuple(normalization=CMP_NORMALIZATION)) + second = cast(TimespanTuple, other.timespan_tuple(normalization=CMP_NORMALIZATION)) + self.__require_tuple_components(first + second, False, True, True, True) + + # the timespan doesn't include its end instant / day + return second.begin <= first.end < second.end + + def intersects(self, other: "Timespan") -> bool: + first = cast(TimespanTuple, self.timespan_tuple(normalization=CMP_NORMALIZATION)) + second = cast(TimespanTuple, other.timespan_tuple(normalization=CMP_NORMALIZATION)) + self.__require_tuple_components(first + second, True, True, True, True) + + # the timespan doesn't include its end instant / day + return second.begin <= first.begin < second.end or \ + second.begin <= first.end < second.end or \ + first.begin <= second.begin < first.end or \ + first.begin <= second.end < first.end + + def includes(self, other: Union["Timespan", datetime]) -> bool: + if isinstance(other, datetime): + first = cast(TimespanTuple, self.timespan_tuple(normalization=CMP_NORMALIZATION)) + other = CMP_NORMALIZATION.normalize(other) + self.__require_tuple_components(first, True, True) + + # the timespan doesn't include its end instant / day + return first.begin <= other < first.end + + else: + first = cast(TimespanTuple, self.timespan_tuple(normalization=CMP_NORMALIZATION)) + second = cast(TimespanTuple, other.timespan_tuple(normalization=CMP_NORMALIZATION)) + self.__require_tuple_components(first + second, True, True, True, True) + + # the timespan doesn't include its end instant / day + return first.begin <= second.begin and second.end < first.end + + __contains__ = includes + + def is_included_in(self, other: "Timespan") -> bool: + first = cast(TimespanTuple, self.timespan_tuple(normalization=CMP_NORMALIZATION)) + second = cast(TimespanTuple, other.timespan_tuple(normalization=CMP_NORMALIZATION)) + self.__require_tuple_components(first + second, True, True, True, True) + + # the timespan doesn't include its end instant / day + return second.begin <= first.begin and first.end < second.end + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Timespan): + return self.cmp_tuple() < other.cmp_tuple() + else: + return NotImplemented + + def __gt__(self, other: Any) -> bool: + if isinstance(other, Timespan): + return self.cmp_tuple() > other.cmp_tuple() + else: + return NotImplemented + + def __le__(self, other: Any) -> bool: + if isinstance(other, Timespan): + return self.cmp_tuple() <= other.cmp_tuple() + else: + return NotImplemented + + def __ge__(self, other: Any) -> bool: + if isinstance(other, Timespan): + return self.cmp_tuple() >= other.cmp_tuple() + else: + return NotImplemented + + +class EventTimespan(Timespan): + def _end_name(self): + return "end" + + def validate(self): + super(EventTimespan, self).validate() + if self.begin_time is None and self.end_time is not None: + raise ValueError("event timespan without begin time can't have end time") + + def get_effective_duration(self) -> timedelta: + if self.duration is not None: + return self.duration + elif self.end_time is not None and self.begin_time is not None: + return self.end_time - self.begin_time + elif self.is_all_day(): + return TIMEDELTA_DAY + else: + return TIMEDELTA_ZERO + + +class TodoTimespan(Timespan): + def _end_name(self): + return "due" + + def timespan_tuple(self, default=None, normalization=None): + # Todos compare by (due, begin) instead of (begin, end) + return tuple(reversed( + super(TodoTimespan, self).timespan_tuple( + default=default, normalization=normalization) + )) diff --git a/src/ics/todo.py b/src/ics/todo.py new file mode 100644 index 00000000..801211f9 --- /dev/null +++ b/src/ics/todo.py @@ -0,0 +1,82 @@ +# mypy: ignore_errors +# this is because mypy doesn't like the converter of CalendarEntryAttrs.{created,last_modified,dtstamp} and due to some +# bug confuses the files + +import functools +import warnings +from datetime import datetime +from typing import Optional + +import attr +from attr.validators import in_, instance_of, optional as v_optional + +from ics.converter.component import ComponentMeta +from ics.event import CalendarEntryAttrs +from ics.timespan import TodoTimespan +from ics.types import DatetimeLike, TimedeltaLike +from ics.utils import ensure_datetime, ensure_timedelta + +MAX_PERCENT = 100 +MAX_PRIORITY = 9 + + +def deprecated_due(fun): + @functools.wraps(fun) + def wrapper(*args, **kwargs): + msg = "Call to deprecated function {}. Use `due` instead of `end` for class Todo." + warnings.warn( + msg.format(fun.__name__), + category=DeprecationWarning + ) + return fun(*args, **kwargs) + + return wrapper + + +@attr.s(eq=True, order=False) # order methods are provided by CalendarEntryAttrs +class TodoAttrs(CalendarEntryAttrs): + percent: Optional[int] = attr.ib(default=None, validator=v_optional(in_(range(0, MAX_PERCENT + 1)))) + priority: Optional[int] = attr.ib(default=None, validator=v_optional(in_(range(0, MAX_PRIORITY + 1)))) + completed: Optional[datetime] = attr.ib(default=None, converter=ensure_datetime) # type: ignore + + +class Todo(TodoAttrs): + """A todo list entry. + + Can have a start time and duration, or start and due time, + or only start or due time. + """ + _timespan: TodoTimespan = attr.ib(validator=instance_of(TodoTimespan)) + + Meta = ComponentMeta("VTODO") + + def __init__( + self, + begin: DatetimeLike = None, + due: DatetimeLike = None, + duration: TimedeltaLike = None, + *args, **kwargs + ): + if (begin is not None or due is not None or duration is not None) and "timespan" in kwargs: + raise ValueError("can't specify explicit timespan together with any of begin, due or duration") + kwargs.setdefault("timespan", TodoTimespan(ensure_datetime(begin), ensure_datetime(due), ensure_timedelta(duration))) + super(Todo, self).__init__(kwargs.pop("timespan"), *args, **kwargs) + + #################################################################################################################### + + def convert_due(self, representation): + if representation == "due": + representation = "end" + super(Todo, self).convert_end(representation) + + due = property(TodoAttrs.end.fget, TodoAttrs.end.fset) # type: ignore + # convert_due = TodoAttrs.convert_end # see above + due_representation = property(TodoAttrs.end_representation.fget) # type: ignore + has_explicit_due = property(TodoAttrs.has_explicit_end.fget) # type: ignore + due_within = TodoAttrs.ends_within + + end = property(deprecated_due(TodoAttrs.end.fget), deprecated_due(TodoAttrs.end.fset)) # type: ignore + convert_end = deprecated_due(TodoAttrs.convert_end) + end_representation = property(deprecated_due(TodoAttrs.end_representation.fget)) # type: ignore + has_explicit_end = property(deprecated_due(TodoAttrs.has_explicit_end.fget)) # type: ignore + ends_within = deprecated_due(TodoAttrs.ends_within) diff --git a/src/ics/types.py b/src/ics/types.py new file mode 100644 index 00000000..2ae68d15 --- /dev/null +++ b/src/ics/types.py @@ -0,0 +1,176 @@ +import functools +import warnings +from datetime import date, datetime, timedelta +from typing import Any, Dict, Iterator, List, MutableMapping, NewType, Optional, TYPE_CHECKING, Tuple, Union, cast, overload +from urllib.parse import ParseResult + +import attr + +if TYPE_CHECKING: + # noinspection PyUnresolvedReferences + from ics.event import Event, CalendarEntryAttrs + # noinspection PyUnresolvedReferences + from ics.todo import Todo + # noinspection PyUnresolvedReferences + from ics.timespan import Timespan + # noinspection PyUnresolvedReferences + from ics.grammar import ContentLine, Container + +__all__ = [ + "ContainerItem", "ContainerList", "URL", + + "DatetimeLike", "OptionalDatetimeLike", + "TimedeltaLike", "OptionalTimedeltaLike", + + "TimespanOrBegin", + "EventOrTimespan", + "EventOrTimespanOrInstant", + "TodoOrTimespan", + "TodoOrTimespanOrInstant", + "CalendarEntryOrTimespan", + "CalendarEntryOrTimespanOrInstant", + + "get_timespan_if_calendar_entry", + + "RuntimeAttrValidation", + + "EmptyDict", "ExtraParams", "EmptyParams", "ContextDict", "EmptyContext", "copy_extra_params", +] + +ContainerItem = Union["ContentLine", "Container"] +ContainerList = List[ContainerItem] +URL = ParseResult + +DatetimeLike = Union[Tuple, Dict, datetime, date] +OptionalDatetimeLike = Union[Tuple, Dict, datetime, date, None] +TimedeltaLike = Union[Tuple, Dict, timedelta] +OptionalTimedeltaLike = Union[Tuple, Dict, timedelta, None] + +TimespanOrBegin = Union[datetime, date, "Timespan"] +EventOrTimespan = Union["Event", "Timespan"] +EventOrTimespanOrInstant = Union["Event", "Timespan", datetime] +TodoOrTimespan = Union["Todo", "Timespan"] +TodoOrTimespanOrInstant = Union["Todo", "Timespan", datetime] +CalendarEntryOrTimespan = Union["CalendarEntryAttrs", "Timespan"] +CalendarEntryOrTimespanOrInstant = Union["CalendarEntryAttrs", "Timespan", datetime] + + +@overload +def get_timespan_if_calendar_entry(value: CalendarEntryOrTimespan) -> "Timespan": + ... + + +@overload +def get_timespan_if_calendar_entry(value: datetime) -> datetime: + ... + + +@overload +def get_timespan_if_calendar_entry(value: None) -> None: + ... + + +def get_timespan_if_calendar_entry(value): + from ics.event import CalendarEntryAttrs # noqa + + if isinstance(value, CalendarEntryAttrs): + return value._timespan + else: + return value + + +@attr.s +class RuntimeAttrValidation(object): + """ + Mixin that automatically calls the converters and validators of `attr` attributes. + The library itself only calls these in the generated `__init__` method, with + this mixin they are also called when later (re-)assigning an attribute, which + is handled by `__setattr__`. This makes setting attributes as versatile as specifying + them as init parameters and also ensures that the guarantees of validators are + preserved even after creation of the object, at a small runtime cost. + """ + + def __attrs_post_init__(self): + self.__post_init__ = True + + def __setattr__(self, key, value): + if getattr(self, "__post_init__", None): + cls = self.__class__ # type: Any + if not getattr(cls, "__attr_fields__", None): + cls.__attr_fields__ = attr.fields_dict(cls) + try: + field = cls.__attr_fields__[key] + except KeyError: + pass + else: # when no KeyError was thrown + if field.converter is not None: + value = field.converter(value) + if field.validator is not None: + field.validator(self, field, value) + super(RuntimeAttrValidation, self).__setattr__(key, value) + + +class EmptyDictType(MutableMapping[Any, None]): + """An empty, immutable dict that returns `None` for any key. Useful as default value for function arguments.""" + + def __getitem__(self, k: Any) -> None: + return None + + def __setitem__(self, k: Any, v: None) -> None: + warnings.warn("%s[%r] = %s ignored" % (self.__class__.__name__, k, v)) + return + + def __delitem__(self, v: Any) -> None: + warnings.warn("del %s[%r] ignored" % (self.__class__.__name__, v)) + return + + def __len__(self) -> int: + return 0 + + def __iter__(self) -> Iterator[Any]: + return iter([]) + + +EmptyDict = EmptyDictType() +ExtraParams = NewType("ExtraParams", Dict[str, List[str]]) +EmptyParams = cast("ExtraParams", EmptyDict) +ContextDict = NewType("ContextDict", Dict[Any, Any]) +EmptyContext = cast("ContextDict", EmptyDict) + + +def copy_extra_params(old: Optional[ExtraParams]) -> ExtraParams: + new: ExtraParams = ExtraParams(dict()) + if not old: + return new + for key, value in old.items(): + if isinstance(value, str): + new[key] = value + elif isinstance(value, list): + new[key] = list(value) + else: + raise ValueError("can't convert extra param %s with value of type %s: %s" % (key, type(value), value)) + return new + + +def attrs_custom_init(cls): + assert attr.has(cls) + attr_init = cls.__init__ + custom_init = cls.__attr_custom_init__ + + @functools.wraps(attr_init) + def new_init(self, *args, **kwargs): + custom_init(self, attr_init, *args, **kwargs) + + cls.__init__ = new_init + cls.__attr_custom_init__ = None + del cls.__attr_custom_init__ + return cls + +# @attrs_custom_init +# @attr.s +# class Test(object): +# val1 = attr.ib() +# val2 = attr.ib() +# +# def __attr_custom_init__(self, attr_init, val1, val1_suffix, *args, **kwargs): +# attr_init(self, val1 + val1_suffix, *args, **kwargs) diff --git a/src/ics/utils.py b/src/ics/utils.py new file mode 100644 index 00000000..38153e20 --- /dev/null +++ b/src/ics/utils.py @@ -0,0 +1,228 @@ +from datetime import date, datetime, time, timedelta, timezone +from typing import overload +from uuid import uuid4 + +from dateutil.tz import UTC as dateutil_tzutc + +from ics.types import DatetimeLike, TimedeltaLike + +datetime_tzutc = timezone.utc + +MIDNIGHT = time() +TIMEDELTA_ZERO = timedelta() +TIMEDELTA_DAY = timedelta(days=1) +TIMEDELTA_SECOND = timedelta(seconds=1) +TIMEDELTA_CACHE = { + 0: TIMEDELTA_ZERO, + "day": TIMEDELTA_DAY, + "second": TIMEDELTA_SECOND +} +MAX_TIMEDELTA_NEARLY_ZERO = timedelta(seconds=1) / 2 + + +@overload +def ensure_datetime(value: None) -> None: ... + + +@overload +def ensure_datetime(value: DatetimeLike) -> datetime: ... + + +def ensure_datetime(value): + if value is None: + return None + elif isinstance(value, datetime): + return value + elif isinstance(value, date): + return datetime.combine(value, MIDNIGHT, tzinfo=None) + elif isinstance(value, tuple): + return datetime(*value) + elif isinstance(value, dict): + return datetime(**value) + else: + raise ValueError("can't construct datetime from %s" % repr(value)) + + +@overload +def ensure_utc(value: None) -> None: ... + + +@overload +def ensure_utc(value: DatetimeLike) -> datetime: ... + + +def ensure_utc(value): + value = ensure_datetime(value) + if value is not None: + value = value.astimezone(dateutil_tzutc) + return value + + +def now_in_utc() -> datetime: + return datetime.now(tz=dateutil_tzutc) + + +def is_utc(instant: datetime) -> bool: + tz = instant.tzinfo + if tz is None: + return False + if tz in [dateutil_tzutc, datetime_tzutc]: + return True + tzname = tz.tzname(instant) + if tzname and tzname.upper() == "UTC": + return True + return False + + +@overload +def ensure_timedelta(value: None) -> None: ... + + +@overload +def ensure_timedelta(value: TimedeltaLike) -> timedelta: ... + + +def ensure_timedelta(value): + if value is None: + return None + elif isinstance(value, timedelta): + return value + elif isinstance(value, tuple): + return timedelta(*value) + elif isinstance(value, dict): + return timedelta(**value) + else: + raise ValueError("can't construct timedelta from %s" % repr(value)) + + +############################################################################### +# Rounding Utils + +def timedelta_nearly_zero(td: timedelta) -> bool: + return -MAX_TIMEDELTA_NEARLY_ZERO <= td <= MAX_TIMEDELTA_NEARLY_ZERO + + +@overload +def floor_datetime_to_midnight(value: datetime) -> datetime: ... + + +@overload +def floor_datetime_to_midnight(value: date) -> date: ... + + +@overload +def floor_datetime_to_midnight(value: None) -> None: ... + + +def floor_datetime_to_midnight(value): + if value is None: + return None + if isinstance(value, date) and not isinstance(value, datetime): + return value + return datetime.combine(ensure_datetime(value).date(), MIDNIGHT, tzinfo=value.tzinfo) + + +@overload +def ceil_datetime_to_midnight(value: datetime) -> datetime: ... + + +@overload +def ceil_datetime_to_midnight(value: date) -> date: ... + + +@overload +def ceil_datetime_to_midnight(value: None) -> None: ... + + +def ceil_datetime_to_midnight(value): + if value is None: + return None + if isinstance(value, date) and not isinstance(value, datetime): + return value + floored = floor_datetime_to_midnight(value) + if floored != value: + return floored + TIMEDELTA_DAY + else: + return floored + + +def floor_timedelta_to_days(value: timedelta) -> timedelta: + return value - (value % TIMEDELTA_DAY) + + +def ceil_timedelta_to_days(value: timedelta) -> timedelta: + mod = value % TIMEDELTA_DAY + if mod == TIMEDELTA_ZERO: + return value + else: + return value + TIMEDELTA_DAY - mod + + +############################################################################### +# String Utils + + +def limit_str_length(val): + return str(val) # TODO limit_str_length + + +def next_after_str_escape(it, full_str): + try: + return next(it) + except StopIteration as e: + raise ValueError("value '%s' may not end with an escape sequence" % full_str) from e + + +def uid_gen() -> str: + uid = str(uuid4()) + return "{}@{}.org".format(uid, uid[:4]) + + +############################################################################### + +def validate_not_none(inst, attr, value): + if value is None: + raise ValueError( + "'{name}' may not be None".format( + name=attr.name + ) + ) + + +def validate_truthy(inst, attr, value): + if not bool(value): + raise ValueError( + "'{name}' must be truthy (got {value!r})".format( + name=attr.name, value=value + ) + ) + + +def check_is_instance(name, value, clazz): + if not isinstance(value, clazz): + raise TypeError( + "'{name}' must be {type!r} (got {value!r} that is a " + "{actual!r}).".format( + name=name, + type=clazz, + actual=value.__class__, + value=value, + ), + name, + clazz, + value, + ) + + +def validate_utc(inst, attr, value): + check_is_instance(attr.name, value, datetime) + if not is_utc(value): + raise ValueError( + "'{name}' must be in timezone UTC (got {value!r} which has tzinfo {tzinfo!r})".format( + name=attr.name, value=value, tzinfo=value.tzinfo + ) + ) + + +def call_validate_on_inst(inst, attr, value): + inst.validate(attr, value) diff --git a/src/ics/valuetype/__init__.py b/src/ics/valuetype/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/ics/valuetype/base.py b/src/ics/valuetype/base.py new file mode 100644 index 00000000..280b55c2 --- /dev/null +++ b/src/ics/valuetype/base.py @@ -0,0 +1,50 @@ +import abc +import inspect +from typing import Dict, Generic, Iterable, Type, TypeVar + +from ics.types import ContextDict, EmptyContext, EmptyParams, ExtraParams + +T = TypeVar('T') + + +class ValueConverter(abc.ABC, Generic[T]): + BY_NAME: Dict[str, "ValueConverter"] = {} + BY_TYPE: Dict[Type, "ValueConverter"] = {} + INST: "ValueConverter" + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + if not inspect.isabstract(cls): + cls.INST = cls() + ValueConverter.BY_NAME[cls.INST.ics_type] = cls.INST + ValueConverter.BY_TYPE.setdefault(cls.INST.python_type, cls.INST) + + @property + @abc.abstractmethod + def ics_type(self) -> str: + pass + + @property + @abc.abstractmethod + def python_type(self) -> Type[T]: + pass + + def split_value_list(self, values: str) -> Iterable[str]: + yield from values.split(",") + + def join_value_list(self, values: Iterable[str]) -> str: + return ",".join(values) + + @abc.abstractmethod + def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> T: + pass + + @abc.abstractmethod + def serialize(self, value: T, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: + pass + + def __str__(self): + return "<" + self.__class__.__name__ + ">" + + def __hash__(self): + return hash(type(self)) diff --git a/src/ics/valuetype/datetime.py b/src/ics/valuetype/datetime.py new file mode 100644 index 00000000..c49dedd1 --- /dev/null +++ b/src/ics/valuetype/datetime.py @@ -0,0 +1,294 @@ +import re +import warnings +from datetime import date, datetime, time, timedelta +from typing import List, Optional, Type, cast + +from dateutil.tz import UTC as dateutil_tzutc, gettz, tzoffset as UTCOffset + +from ics.timespan import Timespan +from ics.types import ContextDict, EmptyContext, EmptyParams, ExtraParams, copy_extra_params +from ics.utils import is_utc +from ics.valuetype.base import ValueConverter + + +class DatetimeConverterMixin(object): + FORMATS = { + 6: "%Y%m", + 8: "%Y%m%d" + } + CONTEXT_KEY_AVAILABLE_TZ = "DatetimeAvailableTimezones" + + def _serialize_dt(self, value: datetime, params: ExtraParams, context: ContextDict, + utc_fmt="%Y%m%dT%H%M%SZ", nonutc_fmt="%Y%m%dT%H%M%S") -> str: + if is_utc(value): + return value.strftime(utc_fmt) + else: + if value.tzinfo is not None: + tzname = value.tzinfo.tzname(value) + if not tzname: + # TODO generate unique identifier as name + raise ValueError("could not generate name for tzinfo %s" % value.tzinfo) + params["TZID"] = [tzname] + available_tz = context.setdefault(self.CONTEXT_KEY_AVAILABLE_TZ, {}) + available_tz.setdefault(tzname, value.tzinfo) + return value.strftime(nonutc_fmt) + + def _parse_dt(self, value: str, params: ExtraParams, context: ContextDict, + warn_no_avail_tz=True) -> datetime: + param_tz_list: Optional[List[str]] = params.pop("TZID", None) # we remove the TZID from context + if param_tz_list: + if len(param_tz_list) > 1: + raise ValueError("got multiple TZIDs") + param_tz: Optional[str] = param_tz_list[0] + else: + param_tz = None + available_tz = context.get(self.CONTEXT_KEY_AVAILABLE_TZ, None) + if available_tz is None and warn_no_avail_tz: + warnings.warn("DatetimeConverterMixin.parse called without available_tz dict in context") + fixed_utc = (value[-1].upper() == 'Z') + + value = value.translate({ + ord("/"): "", + ord("-"): "", + ord("Z"): "", + ord("z"): ""}) + dt = datetime.strptime(value, self.FORMATS[len(value)]) + + if fixed_utc: + if param_tz: + raise ValueError("can't specify UTC via appended 'Z' and TZID param '%s'" % param_tz) + return dt.replace(tzinfo=dateutil_tzutc) + elif param_tz: + selected_tz = None + if available_tz: + selected_tz = available_tz.get(param_tz, None) + if selected_tz is None: + selected_tz = gettz(param_tz) # be lenient with missing vtimezone definitions + return dt.replace(tzinfo=selected_tz) + else: + return dt + + +class DatetimeConverter(DatetimeConverterMixin, ValueConverter[datetime]): + FORMATS = { + **DatetimeConverterMixin.FORMATS, + 11: "%Y%m%dT%H", + 13: "%Y%m%dT%H%M", + 15: "%Y%m%dT%H%M%S" + } + + @property + def ics_type(self) -> str: + return "DATE-TIME" + + @property + def python_type(self) -> Type[datetime]: + return datetime + + def serialize(self, value: datetime, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: + return self._serialize_dt(value, params, context) + + def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> datetime: + return self._parse_dt(value, params, context) + + +class DateConverter(DatetimeConverterMixin, ValueConverter[date]): + @property + def ics_type(self) -> str: + return "DATE" + + @property + def python_type(self) -> Type[date]: + return date + + def serialize(self, value, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext): + return value.strftime("%Y%m%d") + + def parse(self, value, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext): + return self._parse_dt(value, params, context, warn_no_avail_tz=False).date() + + +class TimeConverter(DatetimeConverterMixin, ValueConverter[time]): + FORMATS = { + 2: "%H", + 4: "%H%M", + 6: "%H%M%S" + } + + @property + def ics_type(self) -> str: + return "TIME" + + @property + def python_type(self) -> Type[time]: + return time + + def serialize(self, value, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext): + return self._serialize_dt(value, params, context, utc_fmt="%H%M%SZ", nonutc_fmt="%H%M%S") + + def parse(self, value, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext): + return self._parse_dt(value, params, context).timetz() + + +class UTCOffsetConverter(ValueConverter[UTCOffset]): + @property + def ics_type(self) -> str: + return "UTC-OFFSET" + + @property + def python_type(self) -> Type[UTCOffset]: + return UTCOffset + + def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> UTCOffset: + match = re.fullmatch(r"(?P\+|-|)(?P[0-9]{2})(?P[0-9]{2})(?P[0-9]{2})?", value) + if not match: + raise ValueError("value '%s' is not a valid UTCOffset") + groups = match.groupdict() + sign = groups.pop("sign") + td = timedelta(**{k: int(v) for k, v in groups.items() if v}) + if sign == "-": + td *= -1 + return UTCOffset(value, td) + + def serialize(self, value: UTCOffset, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: + offset = value.utcoffset(None) + assert offset is not None + seconds = offset.seconds + if seconds < 0: + res = "-" + else: + res = "+" + + # hours + res += '%02d' % (seconds // 3600) + seconds %= 3600 + + # minutes + res += '%02d' % (seconds // 60) + seconds %= 60 + + if seconds: + # seconds + res += '%02d' % seconds + + return res + + +class DurationConverter(ValueConverter[timedelta]): + @property + def ics_type(self) -> str: + return "DURATION" + + @property + def python_type(self) -> Type[timedelta]: + return timedelta + + def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> timedelta: + DAYS = {'D': 1, 'W': 7} + SECS = {'S': 1, 'M': 60, 'H': 3600} + + sign, i = 1, 0 + if value[i] in '-+': + if value[i] == '-': + sign = -1 + i += 1 + if value[i] != 'P': + raise ValueError("Error while parsing %s" % value) + i += 1 + days, secs = 0, 0 + while i < len(value): + if value[i] == 'T': + i += 1 + if i == len(value): + break + j = i + while value[j].isdigit(): + j += 1 + if i == j: + raise ValueError("Error while parsing %s" % value) + val = int(value[i:j]) + if value[j] in DAYS: + days += val * DAYS[value[j]] + DAYS.pop(value[j]) + elif value[j] in SECS: + secs += val * SECS[value[j]] + SECS.pop(value[j]) + else: + raise ValueError("Error while parsing %s" % value) + i = j + 1 + return timedelta(sign * days, sign * secs) + + def serialize(self, value: timedelta, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: + ONE_DAY_IN_SECS = 3600 * 24 + total = abs(int(value.total_seconds())) + days = total // ONE_DAY_IN_SECS + seconds = total % ONE_DAY_IN_SECS + + res = '' + if days: + res += str(days) + 'D' + if seconds: + res += 'T' + if seconds // 3600: + res += str(seconds // 3600) + 'H' + seconds %= 3600 + if seconds // 60: + res += str(seconds // 60) + 'M' + seconds %= 60 + if seconds: + res += str(seconds) + 'S' + + if not res: + res = 'T0S' + if value.total_seconds() >= 0: + return 'P' + res + else: + return '-P%s' % res + + +class PeriodConverter(DatetimeConverterMixin, ValueConverter[Timespan]): + + @property + def ics_type(self) -> str: + return "PERIOD" + + @property + def python_type(self) -> Type[Timespan]: + return Timespan + + def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext): + start, sep, end = value.partition("/") + if not sep: + raise ValueError("PERIOD '%s' must contain the separator '/'") + if end.startswith("P"): # period-start = date-time "/" dur-value + return Timespan(begin_time=self._parse_dt(start, params, context), + duration=DurationConverter.INST.parse(end, params, context)) + else: # period-explicit = date-time "/" date-time + end_params = copy_extra_params(params) # ensure that the first parse doesn't remove TZID also needed by the second call + return Timespan(begin_time=self._parse_dt(start, params, context), + end_time=self._parse_dt(end, end_params, context)) + + def serialize(self, value: Timespan, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: + # note: there are no DATE to DATE / all-day periods + begin = value.get_begin() + if begin is None: + raise ValueError("PERIOD must have a begin timestamp") + if value.get_end_representation() == "duration": + duration = cast(timedelta, value.get_effective_duration()) + return "%s/%s" % ( + self._serialize_dt(begin, params, context), + DurationConverter.INST.serialize(duration, params, context) + ) + else: + end = value.get_effective_end() + if end is None: + raise ValueError("PERIOD must have a end timestamp") + end_params = copy_extra_params(params) + res = "%s/%s" % ( + self._serialize_dt(begin, params, context), + self._serialize_dt(end, end_params, context) + ) + if end_params != params: + raise ValueError("Begin and end time of PERIOD %s must serialize to the same params! " + "Got %s != %s." % (value, params, end_params)) + return res diff --git a/src/ics/valuetype/generic.py b/src/ics/valuetype/generic.py new file mode 100644 index 00000000..4c2cdc22 --- /dev/null +++ b/src/ics/valuetype/generic.py @@ -0,0 +1,144 @@ +import base64 +from typing import Type +from urllib.parse import urlparse + +from dateutil.rrule import rrule + +from ics.types import ContextDict, EmptyContext, EmptyParams, ExtraParams, URL +from ics.valuetype.base import ValueConverter + + +class BinaryConverter(ValueConverter[bytes]): + + @property + def ics_type(self) -> str: + return "BINARY" + + @property + def python_type(self) -> Type[bytes]: + return bytes + + def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> bytes: + return base64.b64decode(value) + + def serialize(self, value: bytes, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: + return base64.b64encode(value).decode("ascii") + + +ValueConverter.BY_TYPE[bytearray] = ValueConverter.BY_TYPE[bytes] + + +class BooleanConverter(ValueConverter[bool]): + + @property + def ics_type(self) -> str: + return "BOOLEAN" + + @property + def python_type(self) -> Type[bool]: + return bool + + def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> bool: + if value == "TRUE": + return True + elif value == "FALSE": + return False + else: + value = value.upper() + if value == "TRUE": + return True + elif value == "FALSE": + return False + elif value in ["T", "Y", "YES", "ON", "1"]: + return True + elif value in ["F", "N", "NO", "OFF", "0"]: + return False + else: + raise ValueError("can't interpret '%s' as boolen" % value) + + def serialize(self, value: bool, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: + if value: + return "TRUE" + else: + return "FALSE" + + +class IntegerConverter(ValueConverter[int]): + + @property + def ics_type(self) -> str: + return "INTEGER" + + @property + def python_type(self) -> Type[int]: + return int + + def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> int: + return int(value) + + def serialize(self, value: int, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: + return str(value) + + +class FloatConverter(ValueConverter[float]): + + @property + def ics_type(self) -> str: + return "FLOAT" + + @property + def python_type(self) -> Type[float]: + return float + + def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> float: + return float(value) + + def serialize(self, value: float, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: + return str(value) + + +class RecurConverter(ValueConverter[rrule]): + + @property + def ics_type(self) -> str: + return "RECUR" + + @property + def python_type(self) -> Type[rrule]: + return rrule + + def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> rrule: + # this won't be called unless a class specifies an attribute with type: rrule + raise NotImplementedError("parsing 'RECUR' is not yet supported") # TODO is this a valuetype or a composed object + + def serialize(self, value: rrule, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: + raise NotImplementedError("serializing 'RECUR' is not yet supported") + + +class URIConverter(ValueConverter[URL]): + # TODO URI PARAMs need percent escaping, preventing all illegal characters except for ", in which they also need to wrapped + # TODO URI values also need percent escaping (escaping COMMA characters in URI Lists), but no quoting + + @property + def ics_type(self) -> str: + return "URI" + + @property + def python_type(self) -> Type[URL]: + return URL + + def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> URL: + return urlparse(value) + + def serialize(self, value: URL, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: + if isinstance(value, str): + return value + else: + return value.geturl() + + +class CalendarUserAddressConverter(URIConverter): + + @property + def ics_type(self) -> str: + return "CAL-ADDRESS" diff --git a/src/ics/valuetype/special.py b/src/ics/valuetype/special.py new file mode 100644 index 00000000..60698a59 --- /dev/null +++ b/src/ics/valuetype/special.py @@ -0,0 +1,25 @@ +from typing import Type + +from ics.geo import Geo +from ics.types import ContextDict, EmptyContext, EmptyParams, ExtraParams +from ics.valuetype.base import ValueConverter + + +class GeoConverter(ValueConverter[Geo]): + + @property + def ics_type(self) -> str: + return "X-GEO" + + @property + def python_type(self) -> Type[Geo]: + return Geo + + def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> Geo: + latitude, sep, longitude = value.partition(";") + if not sep: + raise ValueError("geo must have two float values") + return Geo(float(latitude), float(longitude)) + + def serialize(self, value: Geo, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: + return "%f;%f" % value diff --git a/src/ics/valuetype/text.py b/src/ics/valuetype/text.py new file mode 100644 index 00000000..0a172972 --- /dev/null +++ b/src/ics/valuetype/text.py @@ -0,0 +1,68 @@ +from typing import Iterable, Iterator, Type + +from ics.types import ContextDict, EmptyContext, EmptyParams, ExtraParams +from ics.utils import next_after_str_escape +from ics.valuetype.base import ValueConverter + + +class TextConverter(ValueConverter[str]): + + @property + def ics_type(self) -> str: + return "TEXT" + + @property + def python_type(self) -> Type[str]: + return str + + def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: + return self.unescape_text(value) + + def serialize(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: + return self.escape_text(value) + + def split_value_list(self, values: str) -> Iterable[str]: + it = iter(values.split(",")) + for val in it: + while val.endswith("\\") and not val.endswith("\\\\"): + val += "," + next_after_str_escape(it, full_str=values) + yield val + + # def join_value_list(self, values: Iterable[str]) -> str: + # return ",".join(values) # TODO warn about missing escapes + + @classmethod + def escape_text(cls, string: str) -> str: + return string.translate( + {ord("\\"): "\\\\", + ord(";"): "\\;", + ord(","): "\\,", + ord("\n"): "\\n", + ord("\r"): "\\r"}) + + @classmethod + def unescape_text(cls, string: str) -> str: + return "".join(cls.unescape_text_iter(string)) + + @classmethod + def unescape_text_iter(cls, string: str) -> Iterator[str]: + it = iter(string) + for c1 in it: + if c1 == "\\": + c2 = next_after_str_escape(it, full_str=string) + if c2 == ";": + yield ";" + elif c2 == ",": + yield "," + elif c2 == "n" or c2 == "N": + yield "\n" + elif c2 == "r" or c2 == "R": + yield "\r" + elif c2 == "\\": + yield "\\" + else: + raise ValueError("can't handle escaped character '%s'" % c2) + elif c1 in ";,\n\r": + raise ValueError("unescaped character '%s' in TEXT value" % c1) + else: + yield c1 diff --git a/tests/grammar/__init__.py b/tests/grammar/__init__.py new file mode 100644 index 00000000..799adb70 --- /dev/null +++ b/tests/grammar/__init__.py @@ -0,0 +1,288 @@ +import re + +import pytest +from hypothesis import assume, given +from hypothesis.strategies import characters, text + +from ics.grammar import Container, ContentLine, ParseError, QuotedParamValue, escape_param, string_to_container, unfold_lines + +CONTROL = [chr(i) for i in range(ord(" ")) if i != ord("\t")] +NAME = text(alphabet=(characters(whitelist_categories=["Lu"], whitelist_characters=["-"], max_codepoint=128)), min_size=1) +VALUE = text(characters(blacklist_categories=["Cs"], blacklist_characters=CONTROL)) + + +@pytest.mark.parametrize("inp, out", [ + ('HAHA:', ContentLine(name='HAHA', params={}, value='')), + ('HAHA:hoho', ContentLine(name='HAHA', params={}, value='hoho')), + ('HAHA:hoho:hihi', ContentLine(name='HAHA', params={}, value='hoho:hihi')), + ( + 'HAHA;hoho=1:hoho', + ContentLine(name='HAHA', params={'hoho': ['1']}, value='hoho') + ), ( + 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU', + ContentLine(name='RRULE', params={}, value='FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU') + ), ( + 'SUMMARY:dfqsdfjqkshflqsjdfhqs fqsfhlqs dfkqsldfkqsdfqsfqsfqsfs', + ContentLine(name='SUMMARY', params={}, value='dfqsdfjqkshflqsjdfhqs fqsfhlqs dfkqsldfkqsdfqsfqsfqsfs') + ), ( + 'DTSTART;TZID=Europe/Brussels:20131029T103000', + ContentLine(name='DTSTART', params={'TZID': ['Europe/Brussels']}, value='20131029T103000') + ), ( + 'haha;p2=v2;p1=v1:', + ContentLine(name='HAHA', params={'p2': ['v2'], 'p1': ['v1']}, value='') + ), ( + 'haha;hihi=p3,p4,p5;hoho=p1,p2:blabla:blublu', + ContentLine(name='HAHA', params={'hihi': ['p3', 'p4', 'p5'], 'hoho': ['p1', 'p2']}, value='blabla:blublu') + ), ( + 'ATTENDEE;X-A="I&rsquo\\;ll be in NYC":mailto:a@a.com', + ContentLine(name='ATTENDEE', params={'X-A': ['I&rsquo\\;ll be in NYC']}, value='mailto:a@a.com') + ), ( + 'DTEND;TZID="UTC":20190107T000000', + ContentLine(name='DTEND', params={'TZID': [QuotedParamValue('UTC')]}, value='20190107T000000') + ), ( + "ATTENDEE;MEMBER=\"mailto:ietf-calsch@example.org\":mailto:jsmith@example.com", + ContentLine("ATTENDEE", {"MEMBER": ["mailto:ietf-calsch@example.org"]}, "mailto:jsmith@example.com") + ), ( + "ATTENDEE;MEMBER=\"mailto:projectA@example.com\",\"mailto:projectB@example.com\":mailto:janedoe@example.com", + ContentLine("ATTENDEE", {"MEMBER": ["mailto:projectA@example.com", "mailto:projectB@example.com"]}, "mailto:janedoe@example.com") + ), ( + "RESOURCES:EASEL,PROJECTOR,VCR", + ContentLine("RESOURCES", value="EASEL,PROJECTOR,VCR") + ), ( + "ATTENDEE;CN=George Herman ^'Babe^' Ruth:mailto:babe@example.com", + ContentLine("ATTENDEE", {"CN": ["George Herman \"Babe\" Ruth"]}, "mailto:babe@example.com") + ), ( + "GEO;X-ADDRESS=Pittsburgh Pirates^n115 Federal St^nPittsburgh, PA 15212:40.446816,-80.00566", + ContentLine("GEO", {"X-ADDRESS": ["Pittsburgh Pirates\n115 Federal St\nPittsburgh", " PA 15212"]}, "40.446816,-80.00566") + ), ( + "GEO;X-ADDRESS=\"Pittsburgh Pirates^n115 Federal St^nPittsburgh, PA 15212\":40.446816,-80.00566", + ContentLine("GEO", {"X-ADDRESS": ["Pittsburgh Pirates\n115 Federal St\nPittsburgh, PA 15212"]}, "40.446816,-80.00566") + ), ( + "SUMMARY:Project XYZ Final Review\\nConference Room - 3B\\nCome Prepared.", + ContentLine("SUMMARY", value="Project XYZ Final Review\\nConference Room - 3B\\nCome Prepared.") + ), ( + "DESCRIPTION;ALTREP=\"cid:part1.0001@example.org\":The Fall'98 Wild Wizards Conference - - Las Vegas\\, NV\\, USA", + ContentLine("DESCRIPTION", {"ALTREP": ["cid:part1.0001@example.org"]}, value="The Fall'98 Wild Wizards Conference - - Las Vegas\\, NV\\, USA") + ), + +]) +def test_example_recode(inp, out): + par = ContentLine.parse(inp) + assert par == out + ser = out.serialize() + if inp[0].isupper(): + assert inp == ser + else: + assert inp.upper() == ser.upper() + par_ser = par.serialize() + if inp[0].isupper(): + assert inp == par_ser + else: + assert inp.upper() == par_ser.upper() + assert string_to_container(inp) == [out] + + +def test_param_quoting(): + inp = 'TEST;P1="A";P2=B;P3=C,"D",E,"F":"VAL"' + out = ContentLine("TEST", { + "P1": [QuotedParamValue("A")], + "P2": ["B"], + "P3": ["C", QuotedParamValue("D"), "E", QuotedParamValue("F")], + }, '"VAL"') + par = ContentLine.parse(inp) + assert par == out + ser = out.serialize() + assert inp == ser + par_ser = par.serialize() + assert inp == par_ser + assert string_to_container(inp) == [out] + + for param in out.params.keys(): + for o_val, p_val in zip(out[param], par[param]): + assert type(o_val) == type(p_val) + + +def test_trailing_escape_param(): + with pytest.raises(ValueError) as excinfo: + ContentLine.parse("TEST;PARAM=this ^^ is a ^'param^',with a ^trailing escape^:value") + assert "not end with an escape sequence" in str(excinfo.value) + assert ContentLine.parse("TEST;PARAM=this ^^ is a ^'param^',with a ^trailing escape:value").params["PARAM"] == \ + ["this ^ is a \"param\"", "with a ^trailing escape"] + + +@given(name=NAME, value=VALUE) +def test_any_name_value_recode(name, value): + raw = "%s:%s" % (name, value) + assert ContentLine.parse(raw).serialize() == raw + cl = ContentLine(name, value=value) + assert ContentLine.parse(cl.serialize()) == cl + assert string_to_container(raw) == [cl] + + +def quote_escape_param(pval): + if re.search("[:;,]", pval): + return '"%s"' % escape_param(pval) + else: + return escape_param(pval) + + +@given(param=NAME, value=VALUE) +def test_any_param_value_recode(param, value): + raw = "TEST;%s=%s:VALUE" % (param, quote_escape_param(value)) + assert ContentLine.parse(raw).serialize() == raw + cl = ContentLine("TEST", {param: [value]}, "VALUE") + assert ContentLine.parse(cl.serialize()) == cl + assert string_to_container(raw) == [cl] + + +@given(name=NAME, value=VALUE, + param1=NAME, p1value=VALUE, + param2=NAME, p2value1=VALUE, p2value2=VALUE) +def test_any_name_params_value_recode(name, value, param1, p1value, param2, p2value1, p2value2): + assume(param1 != param2) + raw = "%s;%s=%s;%s=%s,%s:%s" % (name, param1, quote_escape_param(p1value), + param2, quote_escape_param(p2value1), quote_escape_param(p2value2), value) + assert ContentLine.parse(raw).serialize() == raw + cl = ContentLine(name, {param1: [p1value], param2: [p2value1, p2value2]}, value) + assert ContentLine.parse(cl.serialize()) == cl + assert string_to_container(raw) == [cl] + + +def test_contentline_parse_error(): + pytest.raises(ParseError, ContentLine.parse, 'haha;p1=v1') + pytest.raises(ParseError, ContentLine.parse, 'haha;p1:') + + +def test_container(): + inp = """BEGIN:TEST +VAL1:The-Val +VAL2;PARAM1=P1;PARAM2=P2A,P2B;PARAM3="P3:A","P3:B,C":The-Val2 +END:TEST""" + out = Container('TEST', [ + ContentLine(name='VAL1', params={}, value='The-Val'), + ContentLine(name='VAL2', params={'PARAM1': ['P1'], 'PARAM2': ['P2A', 'P2B'], 'PARAM3': ['P3:A', 'P3:B,C']}, value='The-Val2')]) + + assert string_to_container(inp) == [out] + assert out.serialize() == inp.replace("\n", "\r\n") + assert str(out) == "TEST[VAL1='The-Val', VAL2{'PARAM1': ['P1'], 'PARAM2': ['P2A', 'P2B'], 'PARAM3': ['P3:A', 'P3:B,C']}='The-Val2']" + assert repr(out) == "Container('TEST', [ContentLine(name='VAL1', params={}, value='The-Val'), ContentLine(name='VAL2', params={'PARAM1': ['P1'], 'PARAM2': ['P2A', 'P2B'], 'PARAM3': ['P3:A', 'P3:B,C']}, value='The-Val2')])" + + out_shallow = out.clone(deep=False) + out_deep = out.clone(deep=True) + assert out == out_shallow == out_deep + assert all(a == b for a, b in zip(out, out_shallow)) + assert all(a == b for a, b in zip(out, out_deep)) + assert all(a is b for a, b in zip(out, out_shallow)) + assert all(a is not b for a, b in zip(out, out_deep)) + out_deep.append(ContentLine("LAST")) + assert out != out_deep + out[0].params["NEW"] = "SOMETHING" + assert out == out_shallow + out_shallow.name = "DIFFERENT" + assert out != out_shallow + + with pytest.raises(TypeError): + out_shallow[0] = ['CONTENT:Line'] + with pytest.raises(TypeError): + out_shallow[:] = ['CONTENT:Line'] + pytest.raises(TypeError, out_shallow.append, 'CONTENT:Line') + pytest.raises(TypeError, out_shallow.append, ['CONTENT:Line']) + pytest.raises(TypeError, out_shallow.extend, ['CONTENT:Line']) + out_shallow[:] = [out[0]] + assert out_shallow == Container("DIFFERENT", [out[0]]) + out_shallow[:] = [] + assert out_shallow == Container("DIFFERENT") + out_shallow.append(ContentLine("CL1")) + out_shallow.extend([ContentLine("CL3")]) + out_shallow.insert(1, ContentLine("CL2")) + out_shallow += [ContentLine("CL4")] + assert out_shallow[1:3] == Container("DIFFERENT", [ContentLine("CL2"), ContentLine("CL3")]) + assert out_shallow == Container("DIFFERENT", [ContentLine("CL1"), ContentLine("CL2"), ContentLine("CL3"), ContentLine("CL4")]) + + with pytest.warns(UserWarning, match="not all-uppercase"): + assert string_to_container("BEGIN:test\nEND:TeSt") == [Container("TEST", [])] + + +def test_container_nested(): + inp = """BEGIN:TEST1 +VAL1:The-Val +BEGIN:TEST2 +VAL2:The-Val +BEGIN:TEST3 +VAL3:The-Val +END:TEST3 +END:TEST2 +VAL4:The-Val +BEGIN:TEST2 +VAL5:The-Val +END:TEST2 +BEGIN:TEST2 +VAL5:The-Val +END:TEST2 +VAL6:The-Val +END:TEST1""" + out = Container('TEST1', [ + ContentLine(name='VAL1', params={}, value='The-Val'), + Container('TEST2', [ + ContentLine(name='VAL2', params={}, value='The-Val'), + Container('TEST3', [ + ContentLine(name='VAL3', params={}, value='The-Val') + ]) + ]), + ContentLine(name='VAL4', params={}, value='The-Val'), + Container('TEST2', [ + ContentLine(name='VAL5', params={}, value='The-Val')]), + Container('TEST2', [ + ContentLine(name='VAL5', params={}, value='The-Val')]), + ContentLine(name='VAL6', params={}, value='The-Val')]) + + assert string_to_container(inp) == [out] + assert out.serialize() == inp.replace("\n", "\r\n") + + +def test_container_parse_error(): + pytest.raises(ParseError, string_to_container, "BEGIN:TEST") + assert string_to_container("END:TEST") == [ContentLine(name="END", value="TEST")] + pytest.raises(ParseError, string_to_container, "BEGIN:TEST1\nEND:TEST2") + pytest.raises(ParseError, string_to_container, "BEGIN:TEST1\nEND:TEST2\nEND:TEST1") + assert string_to_container("BEGIN:TEST1\nEND:TEST1\nEND:TEST1") == [Container("TEST1"), ContentLine(name="END", value="TEST1")] + pytest.raises(ParseError, string_to_container, "BEGIN:TEST1\nBEGIN:TEST1\nEND:TEST1") + + +def test_unfold(): + val1 = "DESCRIPTION:This is a long description that exists on a long line." + val2 = "DESCRIPTION:This is a lo\n ng description\n that exists on a long line." + assert "".join(unfold_lines(val2.splitlines())) == val1 + assert string_to_container(val1) == string_to_container(val2) == [ContentLine.parse(val1)] + pytest.raises(ValueError, ContentLine.parse, val2) + + +def test_value_characters(): + chars = "abcABC0123456789" "-=_+!$%&*()[]{}<>'@#~/?|`¬€¨ÄÄää´ÁÁááßæÆ \t\\n😜🇪🇺👩🏾‍💻👨🏻‍👩🏻‍👧🏻‍👦🏻xyzXYZ" + special_chars = ";:,\"^" + inp = "TEST;P1={chars};P2={chars},{chars},\"{chars}\",{chars}:{chars}:{chars}{special}".format( + chars=chars, special=special_chars) + out = ContentLine("TEST", {"P1": [chars], "P2": [chars, chars, QuotedParamValue(chars), chars]}, + chars + ":" + chars + special_chars) + par = ContentLine.parse(inp) + assert par == out + ser = out.serialize() + assert inp == ser + par_ser = par.serialize() + assert inp == par_ser + assert string_to_container(inp) == [out] + + +def test_contentline_funcs(): + cl = ContentLine("TEST", {"PARAM": ["VAL"]}, "VALUE") + assert cl["PARAM"] == ["VAL"] + cl["PARAM2"] = ["VALA", "VALB"] + assert cl.params == {"PARAM": ["VAL"], "PARAM2": ["VALA", "VALB"]} + cl_clone = cl.clone() + assert cl == cl_clone and cl is not cl_clone + assert cl.params == cl_clone.params and cl.params is not cl_clone.params + assert cl.params["PARAM2"] == cl_clone.params["PARAM2"] and cl.params["PARAM2"] is not cl_clone.params["PARAM2"] + cl_clone["PARAM2"].append("VALC") + assert cl != cl_clone + assert str(cl) == "TEST{'PARAM': ['VAL'], 'PARAM2': ['VALA', 'VALB']}='VALUE'" + assert str(cl_clone) == "TEST{'PARAM': ['VAL'], 'PARAM2': ['VALA', 'VALB', 'VALC']}='VALUE'" diff --git a/tests/test_ics.py b/tests/test_ics.py deleted file mode 100644 index 8c57dcf0..00000000 --- a/tests/test_ics.py +++ /dev/null @@ -1,5 +0,0 @@ -from ics import __version__ - - -def test_version(): - assert __version__ == '0.1.0' diff --git a/tests/valuetype/__init__.py b/tests/valuetype/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/valuetype/text.py b/tests/valuetype/text.py new file mode 100644 index 00000000..36797afe --- /dev/null +++ b/tests/valuetype/text.py @@ -0,0 +1,80 @@ +import attr +import pytest +from hypothesis import given + +from ics.grammar import ContentLine, string_to_container +from ics.valuetype.text import TextConverter +# Text may be comma-separated multi-value but is never quoted, with the characters [\\;,\n] escaped +from tests.grammar import VALUE + +TextConv: TextConverter = TextConverter.INST + + +@pytest.mark.parametrize("inp_esc, out_uesc", [ + ( + "SUMMARY:Project XYZ Final Review\\nConference Room - 3B\\nCome Prepared.", + ContentLine("SUMMARY", value="Project XYZ Final Review\nConference Room - 3B\nCome Prepared.") + ), + ( + "DESCRIPTION;ALTREP=\"cid:part1.0001@example.org\":The Fall'98 Wild Wizards Conference - - Las Vegas\\, NV\\, USA", + ContentLine("DESCRIPTION", {"ALTREP": ["cid:part1.0001@example.org"]}, value="The Fall'98 Wild Wizards Conference - - Las Vegas, NV, USA") + ), + ( + "TEST:abc\\r\\n\\,\\;:\"\t=xyz", + ContentLine("TEST", value="abc\r\n,;:\"\t=xyz") + ), +]) +def test_example_text_recode(inp_esc, out_uesc): + par_esc = ContentLine.parse(inp_esc) + par_uesc = attr.evolve(par_esc, value=TextConv.parse(par_esc.value)) + out_esc = attr.evolve(out_uesc, value=TextConv.serialize(out_uesc.value)) + assert par_uesc == out_uesc + ser_esc = out_esc.serialize() + assert inp_esc == ser_esc + assert string_to_container(inp_esc) == [par_esc] + + +# TODO list examples ("RESOURCES:EASEL,PROJECTOR,VCR", ContentLine("RESOURCES", value="EASEL,PROJECTOR,VCR")) + +def test_trailing_escape_text(): + with pytest.raises(ValueError) as excinfo: + TextConv.parse("text\\,with\tdangling escape\\") + assert "not end with an escape sequence" in str(excinfo.value) + + assert TextConv.parse("text\\,with\tdangling escape") == "text,with\tdangling escape" + assert TextConv.serialize("text,text\\,with\tdangling escape\\") == "text\\,text\\\\\\,with\tdangling escape\\\\" + + +def test_broken_escape(): + with pytest.raises(ValueError) as e: + TextConv.unescape_text("\\t") + assert e.match("can't handle escaped character") + with pytest.raises(ValueError) as e: + TextConv.unescape_text("abc;def") + assert e.match("unescaped character") + +def test_trailing_escape_value_list(): + cl1 = ContentLine.parse("TEST:this is,a list \\, with a\\\\,trailing escape\\") + with pytest.raises(ValueError) as excinfo: + list(TextConv.split_value_list(cl1.value)) + assert "not end with an escape sequence" in str(excinfo.value) + + cl2 = ContentLine.parse("TEST:this is,a list \\, with a\\\\,trailing escape") + assert list(TextConv.split_value_list(cl2.value)) == \ + ["this is", "a list \\, with a\\\\", "trailing escape"] + assert [TextConv.parse(v) for v in TextConv.split_value_list(cl2.value)] == \ + ["this is", "a list , with a\\", "trailing escape"] + + +@given(value=VALUE) +def test_any_text_value_recode(value): + esc = TextConv.serialize(value) + assert TextConv.parse(esc) == value + cl = ContentLine("TEST", value=esc) + assert ContentLine.parse(cl.serialize()) == cl + assert string_to_container(cl.serialize()) == [cl] + vals = [esc, esc, "test", esc] + cl2 = ContentLine("TEST", value=TextConv.join_value_list(vals)) + assert list(TextConv.split_value_list(cl2.value)) == vals + assert ContentLine.parse(cl.serialize()) == cl + assert string_to_container(cl.serialize()) == [cl] From 19b058f6de94b40f45b1e31062e588a327516e68 Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Fri, 10 Apr 2020 16:10:45 +0200 Subject: [PATCH 17/43] set version --- doc/event.rst | 2 +- poetry.lock | 4 ++-- pyproject.toml | 3 ++- src/ics/__init__.py | 8 ++++---- src/ics/__meta__.py | 7 ------- tests/__init__.py | 8 ++++++++ 6 files changed, 17 insertions(+), 15 deletions(-) delete mode 100644 src/ics/__meta__.py diff --git a/doc/event.rst b/doc/event.rst index 1c480d58..2cefe1ab 100644 --- a/doc/event.rst +++ b/doc/event.rst @@ -4,7 +4,7 @@ First, let’s import the latest version of ics.py :date: >>> import ics >>> ics.__version__ - '0.8dev' + '0.8.0-dev' We’re also going to create a lot of ``datetime`` and ``timedelta`` objects, so we import them as short-hand aliases ``dt`` and ``td``: diff --git a/poetry.lock b/poetry.lock index d268bcc5..e9d50677 100644 --- a/poetry.lock +++ b/poetry.lock @@ -665,7 +665,7 @@ version = "0.1.9" [[package]] category = "main" description = "Backport of pathlib-compatible object wrapper for zip files" -marker = "python_version >= \"3.5\" and python_version < \"3.8\" or python_version < \"3.8\"" +marker = "python_version >= \"3.5\" and python_version < \"3.8\" or python_version < \"3.8\" or python_version >= \"3.5\" and python_version < \"3.8\" and (python_version >= \"3.5\" and python_version < \"3.8\" or python_version < \"3.8\")" name = "zipp" optional = false python-versions = ">=3.6" @@ -676,7 +676,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] [metadata] -content-hash = "e7eb35d0b51c26096fd35f965980fe1775137f3ee8282686b5fbd1a90e7e2869" +content-hash = "7034dbca1487d23a411858d7ed7f1769806deb40029cda13224b4f2cdf8db989" python-versions = "^3.7" [metadata.files] diff --git a/pyproject.toml b/pyproject.toml index 4a793409..163005c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ics" -version = "0.2.0" +version = "0.8.0-dev" description = "Pythonic iCalendar (RFC 5545) Parser" authors = ["Nikita Marchant ", "Niko Fink "] license = "Apache-2.0" @@ -43,6 +43,7 @@ pytest-pep8 = "^1.0.6" pytest-mypy = "^0.6.1" mypy = ">=0.770" hypothesis = "^5.8.0" +packaging = "^20.3" [build-system] requires = ["poetry>=0.12"] diff --git a/src/ics/__init__.py b/src/ics/__init__.py index 50a62f94..91a2436a 100644 --- a/src/ics/__init__.py +++ b/src/ics/__init__.py @@ -13,8 +13,6 @@ def load_converters(): load_converters() # make sure that converters are initialized before any Component classes are defined -from .__meta__ import * # noqa -from .__meta__ import __all__ as all_meta from .alarm import * # noqa from .alarm import __all__ as all_alarms from .attendee import Attendee, Organizer @@ -27,7 +25,6 @@ def load_converters(): from .todo import Todo __all__ = [ - *all_meta, *all_alarms, "Attendee", "Event", @@ -37,5 +34,8 @@ def load_converters(): "EventTimespan", "TodoTimespan", "Todo", - "Component" + "Component", + "__version__" ] + +__version__ = "0.8.0-dev" diff --git a/src/ics/__meta__.py b/src/ics/__meta__.py deleted file mode 100644 index fec9635b..00000000 --- a/src/ics/__meta__.py +++ /dev/null @@ -1,7 +0,0 @@ -__title__ = "ics" -__version__ = "0.8dev" -__author__ = "Nikita Marchant" -__license__ = "Apache License, Version 2.0" -__copyright__ = "Copyright 2013-2020 Nikita Marchant and individual contributors" - -__all__ = ["__title__", "__version__", "__author__", "__license__", "__copyright__"] diff --git a/tests/__init__.py b/tests/__init__.py index e69de29b..1adb4885 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,8 @@ +import pkg_resources +from packaging.version import Version + +import ics + + +def test_version_matches(): + assert Version(ics.__version__) == Version(pkg_resources.get_distribution('ics').version) From 61661692952f4ff78e2ab85dcdeed29ed037c844 Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Fri, 10 Apr 2020 22:20:28 +0200 Subject: [PATCH 18/43] fix sphinx build with poetry --- tox.ini | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 3f884397..bbe3870d 100644 --- a/tox.ini +++ b/tox.ini @@ -4,9 +4,10 @@ envlist = py36, py37, py38, pypy [testenv:docs] description = invoke sphinx-build to build the HTML docs -basepython = python3.7 deps = sphinx >= 1.7.5, < 2 -commands = sphinx-build -d "{toxworkdir}/docs_doctree" doc "{toxworkdir}/docs_out" --color -W -bhtml {posargs} +commands = + poetry install -v + sphinx-build -d "{toxworkdir}/docs_doctree" doc "{toxworkdir}/docs_out" --color -bhtml {posargs} python -c 'import pathlib; print("documentation available under file://\{0\}".format(pathlib.Path(r"{toxworkdir}") / "docs_out" / "index.html"))' [testenv] From 00cd53c2fb06d3d3fbc36de1ec853a8d5e869acb Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Fri, 10 Apr 2020 22:21:25 +0200 Subject: [PATCH 19/43] don't use poetry within tox see https://github.com/python-poetry/poetry/issues/1941#issuecomment-581602064 --- .github/workflows/pythonpackage.yml | 18 +-- poetry.lock | 228 +++++++++++++++------------- pyproject.toml | 38 ++--- tox.ini | 21 +-- 4 files changed, 163 insertions(+), 142 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 8785d217..5168c2bf 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -17,13 +17,11 @@ jobs: uses: actions/setup-python@v1 with: python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip setuptools - pip install -r requirements.txt -r dev/requirements-test.txt - - name: Static checking with mypy - run: | - mypy ics - - name: Run pytest - run: | - python setup.py test + - name: Install poetry and tox + run: python -m pip install --upgrade tox tox-gh-actions + - name: Run tox + run: tox + - name: Publish coverage + uses: coverallsapp/github-action@master + with: + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/poetry.lock b/poetry.lock index e9d50677..08805717 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,25 +1,25 @@ [[package]] -category = "dev" +category = "main" description = "A configurable sidebar-enabled Sphinx theme" name = "alabaster" -optional = false +optional = true python-versions = "*" version = "0.7.12" [[package]] -category = "dev" +category = "main" description = "apipkg: namespace control and lazy-import mechanism" name = "apipkg" -optional = false +optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "1.5" [[package]] -category = "dev" +category = "main" description = "Atomic file writes." marker = "python_version >= \"3.5\" and sys_platform == \"win32\" or sys_platform == \"win32\"" name = "atomicwrites" -optional = false +optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "1.3.0" @@ -38,10 +38,10 @@ docs = ["sphinx", "zope.interface"] tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] [[package]] -category = "dev" +category = "main" description = "Internationalization utilities" name = "babel" -optional = false +optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "2.8.0" @@ -49,35 +49,35 @@ version = "2.8.0" pytz = ">=2015.7" [[package]] -category = "dev" +category = "main" description = "Python package for providing Mozilla's CA Bundle." name = "certifi" -optional = false +optional = true python-versions = "*" version = "2020.4.5.1" [[package]] -category = "dev" +category = "main" description = "Universal encoding detector for Python 2 and 3" name = "chardet" -optional = false +optional = true python-versions = "*" version = "3.0.4" [[package]] -category = "dev" +category = "main" description = "Cross-platform colored terminal text." marker = "python_version >= \"3.5\" and sys_platform == \"win32\" or sys_platform == \"win32\"" name = "colorama" -optional = false +optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" version = "0.4.3" [[package]] -category = "dev" +category = "main" description = "Code coverage measurement for Python" name = "coverage" -optional = false +optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" version = "5.0.4" @@ -85,18 +85,18 @@ version = "5.0.4" toml = ["toml"] [[package]] -category = "dev" +category = "main" description = "Docutils -- Python Documentation Utilities" name = "docutils" -optional = false +optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" version = "0.16" [[package]] -category = "dev" +category = "main" description = "execnet: rapid multi-Python deployment" name = "execnet" -optional = false +optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "1.7.1" @@ -107,18 +107,18 @@ apipkg = ">=1.4" testing = ["pre-commit"] [[package]] -category = "dev" +category = "main" description = "A platform independent file lock." name = "filelock" -optional = false +optional = true python-versions = "*" version = "3.0.12" [[package]] -category = "dev" +category = "main" description = "A library for property-based testing" name = "hypothesis" -optional = false +optional = true python-versions = ">=3.5.2" version = "5.8.0" @@ -138,18 +138,18 @@ pytest = ["pytest (>=4.3)"] pytz = ["pytz (>=2014.1)"] [[package]] -category = "dev" +category = "main" description = "Internationalized Domain Names in Applications (IDNA)" name = "idna" -optional = false +optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "2.9" [[package]] -category = "dev" +category = "main" description = "Getting image size from png/jpeg/jpeg2000/gif file" name = "imagesize" -optional = false +optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "1.2.0" @@ -190,10 +190,10 @@ version = ">=0.4" docs = ["sphinx", "rst.linker", "jaraco.packaging"] [[package]] -category = "dev" +category = "main" description = "A very fast and expressive template engine." name = "jinja2" -optional = false +optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" version = "2.11.1" @@ -204,26 +204,26 @@ MarkupSafe = ">=0.23" i18n = ["Babel (>=0.8)"] [[package]] -category = "dev" +category = "main" description = "Safely add untrusted strings to HTML/XML markup." name = "markupsafe" -optional = false +optional = true python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" version = "1.1.1" [[package]] -category = "dev" +category = "main" description = "More routines for operating on iterables, beyond itertools" name = "more-itertools" -optional = false +optional = true python-versions = ">=3.5" version = "8.2.0" [[package]] -category = "dev" +category = "main" description = "Optional static typing for Python" name = "mypy" -optional = false +optional = true python-versions = ">=3.5" version = "0.770" @@ -236,18 +236,18 @@ typing-extensions = ">=3.7.4" dmypy = ["psutil (>=4.0)"] [[package]] -category = "dev" +category = "main" description = "Experimental type system extensions for programs checked with the mypy typechecker." name = "mypy-extensions" -optional = false +optional = true python-versions = "*" version = "0.4.3" [[package]] -category = "dev" +category = "main" description = "Core utilities for Python packages" name = "packaging" -optional = false +optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "20.3" @@ -256,18 +256,18 @@ pyparsing = ">=2.0.2" six = "*" [[package]] -category = "dev" +category = "main" description = "Python style guide checker" name = "pep8" -optional = false +optional = true python-versions = "*" version = "1.7.1" [[package]] -category = "dev" +category = "main" description = "plugin and hook calling mechanisms for python" name = "pluggy" -optional = false +optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "0.13.1" @@ -280,10 +280,10 @@ version = ">=0.12" dev = ["pre-commit", "tox"] [[package]] -category = "dev" +category = "main" description = "A collection of helpful Python tools!" name = "pockets" -optional = false +optional = true python-versions = "*" version = "0.9.1" @@ -291,42 +291,42 @@ version = "0.9.1" six = ">=1.5.2" [[package]] -category = "dev" +category = "main" description = "library with cross-python path, ini-parsing, io, code, log facilities" name = "py" -optional = false +optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "1.8.1" [[package]] -category = "dev" +category = "main" description = "passive checker of Python programs" name = "pyflakes" -optional = false +optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "2.2.0" [[package]] -category = "dev" +category = "main" description = "Pygments is a syntax highlighting package written in Python." name = "pygments" -optional = false +optional = true python-versions = ">=3.5" version = "2.6.1" [[package]] -category = "dev" +category = "main" description = "Python parsing module" name = "pyparsing" -optional = false +optional = true python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" version = "2.4.7" [[package]] -category = "dev" +category = "main" description = "pytest: simple powerful testing with Python" name = "pytest" -optional = false +optional = true python-versions = ">=3.5" version = "5.4.1" @@ -349,10 +349,10 @@ checkqa-mypy = ["mypy (v0.761)"] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] [[package]] -category = "dev" +category = "main" description = "pytest plugin with mechanisms for caching across test runs" name = "pytest-cache" -optional = false +optional = true python-versions = "*" version = "1.0" @@ -361,10 +361,10 @@ execnet = ">=1.1.dev1" pytest = ">=2.2" [[package]] -category = "dev" +category = "main" description = "Pytest plugin for measuring coverage." name = "pytest-cov" -optional = false +optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "2.8.1" @@ -376,10 +376,10 @@ pytest = ">=3.6" testing = ["fields", "hunter", "process-tests (2.0.2)", "six", "virtualenv"] [[package]] -category = "dev" +category = "main" description = "pytest plugin to check source code with pyflakes" name = "pytest-flakes" -optional = false +optional = true python-versions = "*" version = "4.0.0" @@ -388,10 +388,10 @@ pyflakes = "*" pytest = ">=2.8.0" [[package]] -category = "dev" +category = "main" description = "Mypy static type checker plugin for Pytest" name = "pytest-mypy" -optional = false +optional = true python-versions = "~=3.4" version = "0.6.1" @@ -411,10 +411,10 @@ python = ">=3.5" version = ">=3.5" [[package]] -category = "dev" +category = "main" description = "pytest plugin to check PEP8 requirements" name = "pytest-pep8" -optional = false +optional = true python-versions = "*" version = "1.0.6" @@ -435,18 +435,18 @@ version = "2.8.1" six = ">=1.5" [[package]] -category = "dev" +category = "main" description = "World timezone definitions, modern and historical" name = "pytz" -optional = false +optional = true python-versions = "*" version = "2019.3" [[package]] -category = "dev" +category = "main" description = "Python HTTP for Humans." name = "requests" -optional = false +optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" version = "2.23.0" @@ -469,28 +469,28 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" version = "1.14.0" [[package]] -category = "dev" +category = "main" description = "This package provides 26 stemmers for 25 languages generated from Snowball algorithms." name = "snowballstemmer" -optional = false +optional = true python-versions = "*" version = "2.0.0" [[package]] -category = "dev" +category = "main" description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" name = "sortedcontainers" -optional = false +optional = true python-versions = "*" version = "2.1.0" [[package]] -category = "dev" +category = "main" description = "Python documentation generator" name = "sphinx" -optional = false +optional = true python-versions = ">=3.5" -version = "3.0.0" +version = "3.0.1" [package.dependencies] Jinja2 = ">=2.3" @@ -517,10 +517,10 @@ lint = ["flake8 (>=3.5.0)", "flake8-import-order", "mypy (>=0.770)", "docutils-s test = ["pytest", "pytest-cov", "html5lib", "typed-ast", "cython"] [[package]] -category = "dev" +category = "main" description = "Type hints (PEP 484) support for the Sphinx autodoc extension" name = "sphinx-autodoc-typehints" -optional = false +optional = true python-versions = ">=3.5.2" version = "1.10.3" @@ -532,10 +532,10 @@ test = ["pytest (>=3.1.0)", "typing-extensions (>=3.5)", "sphobjinv (>=2.0)", "d type_comments = ["typed-ast (>=1.4.0)"] [[package]] -category = "dev" +category = "main" description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books" name = "sphinxcontrib-applehelp" -optional = false +optional = true python-versions = ">=3.5" version = "1.0.2" @@ -544,10 +544,10 @@ lint = ["flake8", "mypy", "docutils-stubs"] test = ["pytest"] [[package]] -category = "dev" +category = "main" description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." name = "sphinxcontrib-devhelp" -optional = false +optional = true python-versions = ">=3.5" version = "1.0.2" @@ -556,10 +556,10 @@ lint = ["flake8", "mypy", "docutils-stubs"] test = ["pytest"] [[package]] -category = "dev" +category = "main" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" name = "sphinxcontrib-htmlhelp" -optional = false +optional = true python-versions = ">=3.5" version = "1.0.3" @@ -568,10 +568,10 @@ lint = ["flake8", "mypy", "docutils-stubs"] test = ["pytest", "html5lib"] [[package]] -category = "dev" +category = "main" description = "A sphinx extension which renders display math in HTML via JavaScript" name = "sphinxcontrib-jsmath" -optional = false +optional = true python-versions = ">=3.5" version = "1.0.1" @@ -579,10 +579,10 @@ version = "1.0.1" test = ["pytest", "flake8", "mypy"] [[package]] -category = "dev" +category = "main" description = "Sphinx \"napoleon\" extension." name = "sphinxcontrib-napoleon" -optional = false +optional = true python-versions = "*" version = "0.7" @@ -591,10 +591,10 @@ pockets = ">=0.3" six = ">=1.5.2" [[package]] -category = "dev" +category = "main" description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." name = "sphinxcontrib-qthelp" -optional = false +optional = true python-versions = ">=3.5" version = "1.0.3" @@ -603,10 +603,10 @@ lint = ["flake8", "mypy", "docutils-stubs"] test = ["pytest"] [[package]] -category = "dev" +category = "main" description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." name = "sphinxcontrib-serializinghtml" -optional = false +optional = true python-versions = ">=3.5" version = "1.1.4" @@ -626,26 +626,37 @@ version = "4.4.0" future-regex = ["regex"] [[package]] -category = "dev" +category = "main" +description = "TatSu takes a grammar in a variation of EBNF as input, and outputs a memoizing PEG/Packrat parser in Python." +name = "tatsu" +optional = false +python-versions = ">=3.8" +version = "5.5.0" + +[package.extras] +future-regex = ["regex"] + +[[package]] +category = "main" description = "a fork of Python 2 and 3 ast modules with type comment support" name = "typed-ast" -optional = false +optional = true python-versions = "*" version = "1.4.1" [[package]] -category = "dev" +category = "main" description = "Backported and Experimental Type Hints for Python 3.5+" name = "typing-extensions" -optional = false +optional = true python-versions = "*" version = "3.7.4.2" [[package]] -category = "dev" +category = "main" description = "HTTP library with thread-safe connection pooling, file post, and more." name = "urllib3" -optional = false +optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" version = "1.25.8" @@ -655,17 +666,17 @@ secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "cer socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] [[package]] -category = "dev" +category = "main" description = "Measures number of Terminal column cells of wide-character codes" name = "wcwidth" -optional = false +optional = true python-versions = "*" version = "0.1.9" [[package]] category = "main" description = "Backport of pathlib-compatible object wrapper for zip files" -marker = "python_version >= \"3.5\" and python_version < \"3.8\" or python_version < \"3.8\" or python_version >= \"3.5\" and python_version < \"3.8\" and (python_version >= \"3.5\" and python_version < \"3.8\" or python_version < \"3.8\")" +marker = "python_version >= \"3.5\" and python_version < \"3.8\" or python_version < \"3.8\"" name = "zipp" optional = false python-versions = ">=3.6" @@ -675,9 +686,12 @@ version = "3.1.0" docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] +[extras] +dev = ["pytest", "sphinx", "sphinxcontrib-napoleon", "sphinx-autodoc-typehints", "pytest-cov", "pytest-flakes", "pytest-pep8", "pytest-mypy", "mypy", "hypothesis", "packaging"] + [metadata] -content-hash = "7034dbca1487d23a411858d7ed7f1769806deb40029cda13224b4f2cdf8db989" -python-versions = "^3.7" +content-hash = "d92b5a0c465d0263f6b865d6a2d1d40a2a3badb3d58d30304ba8e925fecdd60b" +python-versions = "^3.6" [metadata.files] alabaster = [ @@ -919,8 +933,8 @@ sortedcontainers = [ {file = "sortedcontainers-2.1.0.tar.gz", hash = "sha256:974e9a32f56b17c1bac2aebd9dcf197f3eb9cd30553c5852a3187ad162e1a03a"}, ] sphinx = [ - {file = "Sphinx-3.0.0-py3-none-any.whl", hash = "sha256:b63a0c879c4ff9a4dffcb05217fa55672ce07abdeb81e33c73303a563f8d8901"}, - {file = "Sphinx-3.0.0.tar.gz", hash = "sha256:6a099e6faffdc3ceba99ca8c2d09982d43022245e409249375edf111caf79ed3"}, + {file = "Sphinx-3.0.1-py3-none-any.whl", hash = "sha256:8411878f4768ec2a8896b844d68070204f9354a831b37937989c2e559d29dffc"}, + {file = "Sphinx-3.0.1.tar.gz", hash = "sha256:50972d83b78990fd61d0d3fe8620814cae53db29443e92c13661bc43dff46ec8"}, ] sphinx-autodoc-typehints = [ {file = "sphinx-autodoc-typehints-1.10.3.tar.gz", hash = "sha256:a6b3180167479aca2c4d1ed3b5cb044a70a76cccd6b38662d39288ebd9f0dff0"}, @@ -957,6 +971,8 @@ sphinxcontrib-serializinghtml = [ tatsu = [ {file = "TatSu-4.4.0-py2.py3-none-any.whl", hash = "sha256:c9211eeee9a2d4c90f69879ec0b518b1aa0d9450249cb0dd181f5f5b18be0a92"}, {file = "TatSu-4.4.0.zip", hash = "sha256:80713413473a009f2081148d0f494884cabaf9d6866b71f2a68a92b6442f343d"}, + {file = "TatSu-5.5.0-py2.py3-none-any.whl", hash = "sha256:3a043490e577632a05374b5033646bbc26cbb17386df81735a569ecbd45d934b"}, + {file = "TatSu-5.5.0.zip", hash = "sha256:0adbf7189a8c4f9a882b442f7b8ed6c6ab3baae37057db0e96b6888daacffad0"}, ] typed-ast = [ {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, diff --git a/pyproject.toml b/pyproject.toml index 163005c8..0f74287c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,24 +26,28 @@ classifiers = [ ] [tool.poetry.dependencies] -python = "^3.7" -python-dateutil = "^2.8.1" -attrs = ">=19.2" -tatsu = ">4.2" -importlib_resources = "^1.4.0" +python = "^3.6" +python-dateutil = "^2.8" +attrs = ">=19.2" +tatsu = ">4.2" +importlib_resources = "^1.4" -[tool.poetry.dev-dependencies] -pytest = "^5.2" -sphinx = "^3.0.0" -sphinxcontrib-napoleon = "^0.7" -sphinx-autodoc-typehints = "^1.10.3" -pytest-cov = "^2.8.1" -pytest-flakes = "^4.0.0" -pytest-pep8 = "^1.0.6" -pytest-mypy = "^0.6.1" -mypy = ">=0.770" -hypothesis = "^5.8.0" -packaging = "^20.3" +# see https://github.com/python-poetry/poetry/issues/1941#issuecomment-581602064 +# [tool.poetry.dev-dependencies] +packaging = { version = ">=20.3", optional = true } +pytest = { version = "^5.2", optional = true } +pytest-cov = { version = "^2.8.1", optional = true } +pytest-flakes = { version = "^4.0.0", optional = true } +pytest-pep8 = { version = "^1.0.6", optional = true } +mypy = { version = ">=0.770", optional = true } +pytest-mypy = { version = "^0.6.1", optional = true } +hypothesis = { version = "^5.8.0", optional = true } +sphinx = { version = "^3.0.0", optional = true } +sphinxcontrib-napoleon = { version = "^0.7", optional = true } +sphinx-autodoc-typehints = { version = "^1.10.3", optional = true } + +[tool.poetry.extras] +dev = ["packaging", "pytest", "pytest-cov", "pytest-flakes", "pytest-pep8", "mypy", "pytest-mypy", "hypothesis", "sphinx", "sphinxcontrib-napoleon", "sphinx-autodoc-typehints"] [build-system] requires = ["poetry>=0.12"] diff --git a/tox.ini b/tox.ini index bbe3870d..dbd87245 100644 --- a/tox.ini +++ b/tox.ini @@ -1,20 +1,23 @@ [tox] isolated_build = true -envlist = py36, py37, py38, pypy +envlist = py36, py37, py38, docs + +[testenv] +extras = + dev +commands = + pytest {posargs} [testenv:docs] -description = invoke sphinx-build to build the HTML docs -deps = sphinx >= 1.7.5, < 2 commands = - poetry install -v sphinx-build -d "{toxworkdir}/docs_doctree" doc "{toxworkdir}/docs_out" --color -bhtml {posargs} python -c 'import pathlib; print("documentation available under file://\{0\}".format(pathlib.Path(r"{toxworkdir}") / "docs_out" / "index.html"))' -[testenv] -whitelist_externals = poetry -commands = - poetry install -v - poetry run pytest +[gh-actions] +python = + 3.6: py36 + 3.7: py37 + 3.8: py38, docs [pytest] python_files = *.py From 03d9ffaca89163fa6d8cdcad8c5f1fdeba479ecd Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Fri, 10 Apr 2020 22:39:49 +0200 Subject: [PATCH 20/43] fix timezone tests --- .github/workflows/pythonpackage.yml | 4 +++- doc/event-cmp.rst | 12 +++++++----- src/ics/timespan.py | 15 +++++++++------ 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 5168c2bf..85f677a8 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -17,7 +17,9 @@ jobs: uses: actions/setup-python@v1 with: python-version: ${{ matrix.python-version }} - - name: Install poetry and tox + - name: Report timezone information + run: python -c "import time; print((time.timezone, time.altzone, time.daylight, time.tzname, time.time()))" + - name: Install tox run: python -m pip install --upgrade tox tox-gh-actions - name: Run tox run: tox diff --git a/doc/event-cmp.rst b/doc/event-cmp.rst index 111a759a..b2b605bf 100644 --- a/doc/event-cmp.rst +++ b/doc/event-cmp.rst @@ -119,7 +119,7 @@ instance as a tuple ``(begin_time, effective_end_time)``: >>> t0 = ics.EventTimespan() >>> t0.cmp_tuple() - TimespanTuple(begin=datetime.datetime(1, 1, 1, 0, 0, tzinfo=tzlocal()), end=datetime.datetime(1, 1, 1, 0, 0, tzinfo=tzlocal())) + TimespanTuple(begin=datetime.datetime(1900, 1, 1, 0, 0, tzinfo=tzlocal()), end=datetime.datetime(1900, 1, 1, 0, 0, tzinfo=tzlocal())) >>> t1 = ics.EventTimespan(begin_time=dt(2020, 2, 20, 20, 20)) >>> t1.cmp_tuple() TimespanTuple(begin=datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal()), end=datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal())) @@ -247,10 +247,10 @@ set: :: >>> import os, time - >>> os.environ['TZ'] = "Europe/Berlin" + >>> os.environ['TZ'] = "Etc/GMT-2" >>> time.tzset() >>> time.tzname - ('CET', 'CEST') + ('+02', '+02') We can easily compare ``datetime`` instances that have an explicit timezone specified: @@ -261,9 +261,11 @@ timezone specified: >>> dt_ny = dt(2020, 2, 20, 20, 20, tzinfo=gettz("America/New York")) >>> dt_utc = dt(2020, 2, 20, 20, 20, tzinfo=tzutc()) >>> dt_local = dt(2020, 2, 20, 20, 20, tzinfo=tzlocal()) + >>> dt_local.tzinfo.tzname(dt_local), dt_local.tzinfo.utcoffset(dt_local) + ('+02', datetime.timedelta(seconds=7200)) >>> dt_utc < dt_ny True - >>> dt_local < dt_utc # this always holds as tzlocal is Europe/Berlin + >>> dt_local < dt_utc # this always holds as tzlocal is +2:00 (i.e. European Summer Time) True We can also compare naive instances with naive ones, but we can’t @@ -287,7 +289,7 @@ which could also be used for comparing instances: >>> (dt_utc.timestamp(), dt_ny.timestamp()) (1582230000.0, 1582248000.0) >>> (dt_local.timestamp(), dt_naive.timestamp()) - (1582226400.0, 1582226400.0) + (1582222800.0, 1582222800.0) This can be become an issue when you e.g. want to iterate all Events of an iCalendar that contains both floating and timezone-aware Events in diff --git a/src/ics/timespan.py b/src/ics/timespan.py index a4a21778..722cb3b2 100644 --- a/src/ics/timespan.py +++ b/src/ics/timespan.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta, tzinfo as TZInfo -from typing import Any, NamedTuple, Optional, TypeVar, Union, cast, overload +from typing import Any, Callable, NamedTuple, Optional, TypeVar, Union, cast, overload import attr from attr.validators import instance_of, optional as v_optional @@ -13,7 +13,7 @@ class Normalization(object): normalize_floating: bool = attr.ib() normalize_with_tz: bool = attr.ib() - replacement: Union[TZInfo, None] = attr.ib() + replacement: Union[TZInfo, Callable[[], TZInfo], None] = attr.ib() @overload def normalize(self, value: "Timespan") -> "Timespan": @@ -47,13 +47,16 @@ def normalize(self, value): # noqa normalize = (floating and self.normalize_floating) or (not floating and self.normalize_with_tz) if normalize: - return replace_timezone(value, self.replacement) + replacement = self.replacement + if callable(replacement): + replacement = replacement() + return replace_timezone(value, replacement) else: return value -CMP_DATETIME_NONE_DEFAULT = datetime.min -CMP_NORMALIZATION = Normalization(normalize_floating=True, normalize_with_tz=False, replacement=tzlocal()) +CMP_DATETIME_NONE_DEFAULT = datetime(1900, 1, 1, 0, 0) +CMP_NORMALIZATION = Normalization(normalize_floating=True, normalize_with_tz=False, replacement=tzlocal) TimespanTuple = NamedTuple("TimespanTuple", [("begin", datetime), ("end", datetime)]) NullableTimespanTuple = NamedTuple("NullableTimespanTuple", [("begin", Optional[datetime]), ("end", Optional[datetime])]) @@ -315,7 +318,7 @@ def timespan_tuple(self, default=None, normalization=None): # noqa ) def cmp_tuple(self) -> TimespanTuple: - return self.timespan_tuple(default=datetime.min, normalization=CMP_NORMALIZATION) + return self.timespan_tuple(default=CMP_DATETIME_NONE_DEFAULT, normalization=CMP_NORMALIZATION) def __require_tuple_components(self, values, *required): for nr, (val, req) in enumerate(zip(values, required)): From 7b4ec8e5259c69c546f7725e64247c783c120854 Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Fri, 10 Apr 2020 23:33:57 +0200 Subject: [PATCH 21/43] change coveralls action --- .github/workflows/pythonpackage.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 85f677a8..57c69bc6 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -20,10 +20,10 @@ jobs: - name: Report timezone information run: python -c "import time; print((time.timezone, time.altzone, time.daylight, time.tzname, time.time()))" - name: Install tox - run: python -m pip install --upgrade tox tox-gh-actions + run: python -m pip install --upgrade tox tox-gh-actions coveralls - name: Run tox run: tox - name: Publish coverage - uses: coverallsapp/github-action@master - with: - github-token: ${{ secrets.GITHUB_TOKEN }} + run: coveralls + env: + COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 7d76bec1de9c24dad97905b234428b23b9f22364 Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Fri, 10 Apr 2020 23:54:23 +0200 Subject: [PATCH 22/43] try codecov --- .github/workflows/pythonpackage.yml | 8 ++++---- tox.ini | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 57c69bc6..21e84e57 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -20,10 +20,10 @@ jobs: - name: Report timezone information run: python -c "import time; print((time.timezone, time.altzone, time.daylight, time.tzname, time.time()))" - name: Install tox - run: python -m pip install --upgrade tox tox-gh-actions coveralls + run: python -m pip install --upgrade tox tox-gh-actions - name: Run tox run: tox - name: Publish coverage - run: coveralls - env: - COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: + rm -rf ./.mypy_cache/; + bash <(curl https://codecov.io/bash) diff --git a/tox.ini b/tox.ini index dbd87245..ae42fcdb 100644 --- a/tox.ini +++ b/tox.ini @@ -46,4 +46,5 @@ addopts = --flakes --pep8 --mypy --cov=ics --doctest-glob='*.rst' --doctest-modules --ignore doc/conf.py --hypothesis-show-statistics + --cov-report=xml -s From b9e6ab9511eed98e57f0b6483656e6132f00a0a5 Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Sat, 11 Apr 2020 13:10:11 +0200 Subject: [PATCH 23/43] bugfixes --- doc/event-cmp.rst | 12 +++++++----- ics/alarm.py | 2 +- ics/timespan.py | 16 ++++++++++------ ics/valuetype/base.py | 7 ++++--- 4 files changed, 22 insertions(+), 15 deletions(-) diff --git a/doc/event-cmp.rst b/doc/event-cmp.rst index 111a759a..f532676b 100644 --- a/doc/event-cmp.rst +++ b/doc/event-cmp.rst @@ -119,7 +119,7 @@ instance as a tuple ``(begin_time, effective_end_time)``: >>> t0 = ics.EventTimespan() >>> t0.cmp_tuple() - TimespanTuple(begin=datetime.datetime(1, 1, 1, 0, 0, tzinfo=tzlocal()), end=datetime.datetime(1, 1, 1, 0, 0, tzinfo=tzlocal())) + TimespanTuple(begin=datetime.datetime(1900, 1, 1, 0, 0, tzinfo=tzlocal()), end=datetime.datetime(1900, 1, 1, 0, 0, tzinfo=tzlocal())) >>> t1 = ics.EventTimespan(begin_time=dt(2020, 2, 20, 20, 20)) >>> t1.cmp_tuple() TimespanTuple(begin=datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal()), end=datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal())) @@ -247,10 +247,10 @@ set: :: >>> import os, time - >>> os.environ['TZ'] = "Europe/Berlin" + >>> os.environ['TZ'] = "Etc/GMT-2" >>> time.tzset() >>> time.tzname - ('CET', 'CEST') + ('+02', '+02') We can easily compare ``datetime`` instances that have an explicit timezone specified: @@ -261,9 +261,11 @@ timezone specified: >>> dt_ny = dt(2020, 2, 20, 20, 20, tzinfo=gettz("America/New York")) >>> dt_utc = dt(2020, 2, 20, 20, 20, tzinfo=tzutc()) >>> dt_local = dt(2020, 2, 20, 20, 20, tzinfo=tzlocal()) + >>> dt_local.tzinfo.tzname(dt_local), dt_local.tzinfo.utcoffset(dt_local).total_seconds() + ('+02', 7200.0) >>> dt_utc < dt_ny True - >>> dt_local < dt_utc # this always holds as tzlocal is Europe/Berlin + >>> dt_local < dt_utc # this always holds as tzlocal is +2:00 (i.e. European Summer Time) True We can also compare naive instances with naive ones, but we can’t @@ -287,7 +289,7 @@ which could also be used for comparing instances: >>> (dt_utc.timestamp(), dt_ny.timestamp()) (1582230000.0, 1582248000.0) >>> (dt_local.timestamp(), dt_naive.timestamp()) - (1582226400.0, 1582226400.0) + (1582222800.0, 1582222800.0) This can be become an issue when you e.g. want to iterate all Events of an iCalendar that contains both floating and timezone-aware Events in diff --git a/ics/alarm.py b/ics/alarm.py index 79589413..71f5509d 100644 --- a/ics/alarm.py +++ b/ics/alarm.py @@ -55,7 +55,7 @@ class AudioAlarm(BaseAlarm): A calendar event VALARM with AUDIO option. """ - attach: Union[URL, bytes] = attr.ib(default="") + attach: Union[URL, bytes, None] = attr.ib(default=None) @property def action(self): diff --git a/ics/timespan.py b/ics/timespan.py index a4a21778..b57a0f09 100644 --- a/ics/timespan.py +++ b/ics/timespan.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta, tzinfo as TZInfo -from typing import Any, NamedTuple, Optional, TypeVar, Union, cast, overload +from typing import Any, Callable, NamedTuple, Optional, TypeVar, Union, cast, overload import attr from attr.validators import instance_of, optional as v_optional @@ -13,7 +13,7 @@ class Normalization(object): normalize_floating: bool = attr.ib() normalize_with_tz: bool = attr.ib() - replacement: Union[TZInfo, None] = attr.ib() + replacement: Union[TZInfo, Callable[[], TZInfo], None] = attr.ib() @overload def normalize(self, value: "Timespan") -> "Timespan": @@ -47,13 +47,17 @@ def normalize(self, value): # noqa normalize = (floating and self.normalize_floating) or (not floating and self.normalize_with_tz) if normalize: - return replace_timezone(value, self.replacement) + replacement = self.replacement + if callable(replacement): + replacement = replacement() + return replace_timezone(value, replacement) else: return value -CMP_DATETIME_NONE_DEFAULT = datetime.min -CMP_NORMALIZATION = Normalization(normalize_floating=True, normalize_with_tz=False, replacement=tzlocal()) +# using datetime.min might lead to problems when doing timezone conversions / comparisions (e.g. by substracting an 1 hour offset) +CMP_DATETIME_NONE_DEFAULT = datetime(1900, 1, 1, 0, 0) +CMP_NORMALIZATION = Normalization(normalize_floating=True, normalize_with_tz=False, replacement=tzlocal) TimespanTuple = NamedTuple("TimespanTuple", [("begin", datetime), ("end", datetime)]) NullableTimespanTuple = NamedTuple("NullableTimespanTuple", [("begin", Optional[datetime]), ("end", Optional[datetime])]) @@ -315,7 +319,7 @@ def timespan_tuple(self, default=None, normalization=None): # noqa ) def cmp_tuple(self) -> TimespanTuple: - return self.timespan_tuple(default=datetime.min, normalization=CMP_NORMALIZATION) + return self.timespan_tuple(default=CMP_DATETIME_NONE_DEFAULT, normalization=CMP_NORMALIZATION) def __require_tuple_components(self, values, *required): for nr, (val, req) in enumerate(zip(values, required)): diff --git a/ics/valuetype/base.py b/ics/valuetype/base.py index 280b55c2..883b84c6 100644 --- a/ics/valuetype/base.py +++ b/ics/valuetype/base.py @@ -7,14 +7,15 @@ T = TypeVar('T') -class ValueConverter(abc.ABC, Generic[T]): +class ValueConverter(Generic[T], abc.ABC): BY_NAME: Dict[str, "ValueConverter"] = {} BY_TYPE: Dict[Type, "ValueConverter"] = {} INST: "ValueConverter" def __init_subclass__(cls) -> None: - super().__init_subclass__() - if not inspect.isabstract(cls): + super(ValueConverter, cls).__init_subclass__() + # isabstract(ValueConverter) == False on python 3.6 + if not inspect.isabstract(cls) and cls.parse is not ValueConverter.parse: cls.INST = cls() ValueConverter.BY_NAME[cls.INST.ics_type] = cls.INST ValueConverter.BY_TYPE.setdefault(cls.INST.python_type, cls.INST) From 03c549c1952b96649509f9552dc3b188a9407074 Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Sat, 11 Apr 2020 14:16:10 +0200 Subject: [PATCH 24/43] add bumpversion --- .bumpversion.cfg | 23 +++++++++++++++++++++++ README.rst | 4 ++-- poetry.lock | 4 ++-- pyproject.toml | 1 + 4 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 .bumpversion.cfg diff --git a/.bumpversion.cfg b/.bumpversion.cfg new file mode 100644 index 00000000..1c5b7a3d --- /dev/null +++ b/.bumpversion.cfg @@ -0,0 +1,23 @@ +[bumpversion] +current_version = 0.8.0-dev +commit = True +tag = True +parse = (?P\d+)\.(?P\d+)\.(?P\d+)-?(?Pdev)? +serialize = + {major}.{minor}.{patch}-{release} + {major}.{minor}.{patch} + +[bumpversion:file:pyproject.toml] + +[bumpversion:file:src/ics/__init__.py] + +[bumpversion:file:doc/event.rst] + +[bumpversion:file:README.rst] + +[bumpversion:part:release] +values = + dev + release +optional_value = release + diff --git a/README.rst b/README.rst index e9ac08ab..79ec1d1b 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ -Ics.py : iCalendar for Humans -============================= +ics.py `0.8.0-dev` : iCalendar for Humans +========================================= `Original repository `_ (GitHub) - `Bugtracker and issues `_ (GitHub) - diff --git a/poetry.lock b/poetry.lock index 08805717..6c91a3cb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -687,10 +687,10 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] [extras] -dev = ["pytest", "sphinx", "sphinxcontrib-napoleon", "sphinx-autodoc-typehints", "pytest-cov", "pytest-flakes", "pytest-pep8", "pytest-mypy", "mypy", "hypothesis", "packaging"] +dev = ["packaging", "pytest", "pytest-cov", "pytest-flakes", "pytest-pep8", "mypy", "pytest-mypy", "hypothesis", "sphinx", "sphinxcontrib-napoleon", "sphinx-autodoc-typehints"] [metadata] -content-hash = "d92b5a0c465d0263f6b865d6a2d1d40a2a3badb3d58d30304ba8e925fecdd60b" +content-hash = "a41d4283c02d5f183761e69a982064900359aa26c1993bc9adb421ead2ebe22f" python-versions = "^3.6" [metadata.files] diff --git a/pyproject.toml b/pyproject.toml index 0f74287c..312a0725 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ attrs = ">=19.2" tatsu = ">4.2" importlib_resources = "^1.4" +# Tools used by tox for testing and building docs # see https://github.com/python-poetry/poetry/issues/1941#issuecomment-581602064 # [tool.poetry.dev-dependencies] packaging = { version = ">=20.3", optional = true } From 20bade0189850d69bc0a773932d66cbd167b3044 Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Sat, 11 Apr 2020 14:23:27 +0200 Subject: [PATCH 25/43] =?UTF-8?q?Bump=20version:=200.8.0-dev=20=E2=86=92?= =?UTF-8?q?=200.8.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit this was done using `bumpversion --verbose release` --- .bumpversion.cfg | 2 +- README.rst | 2 +- doc/event.rst | 2 +- pyproject.toml | 2 +- src/ics/__init__.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 1c5b7a3d..b32d2d9b 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.8.0-dev +current_version = 0.8.0 commit = True tag = True parse = (?P\d+)\.(?P\d+)\.(?P\d+)-?(?Pdev)? diff --git a/README.rst b/README.rst index 79ec1d1b..3fc20dd5 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,4 @@ -ics.py `0.8.0-dev` : iCalendar for Humans +ics.py `0.8.0` : iCalendar for Humans ========================================= `Original repository `_ (GitHub) - diff --git a/doc/event.rst b/doc/event.rst index 2cefe1ab..d67424ca 100644 --- a/doc/event.rst +++ b/doc/event.rst @@ -4,7 +4,7 @@ First, let’s import the latest version of ics.py :date: >>> import ics >>> ics.__version__ - '0.8.0-dev' + '0.8.0' We’re also going to create a lot of ``datetime`` and ``timedelta`` objects, so we import them as short-hand aliases ``dt`` and ``td``: diff --git a/pyproject.toml b/pyproject.toml index 312a0725..1cb62853 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ics" -version = "0.8.0-dev" +version = "0.8.0" description = "Pythonic iCalendar (RFC 5545) Parser" authors = ["Nikita Marchant ", "Niko Fink "] license = "Apache-2.0" diff --git a/src/ics/__init__.py b/src/ics/__init__.py index 91a2436a..f1512085 100644 --- a/src/ics/__init__.py +++ b/src/ics/__init__.py @@ -38,4 +38,4 @@ def load_converters(): "__version__" ] -__version__ = "0.8.0-dev" +__version__ = "0.8.0" From 9fb5bdf3c4357de84ed287104f3f71c6e3723fed Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Sat, 11 Apr 2020 14:25:20 +0200 Subject: [PATCH 26/43] =?UTF-8?q?Bump=20version:=200.8.0=20=E2=86=92=200.9?= =?UTF-8?q?.0-dev?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit this was done using `bumpversion --verbose minor` --- .bumpversion.cfg | 2 +- README.rst | 2 +- doc/event.rst | 2 +- pyproject.toml | 2 +- src/ics/__init__.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index b32d2d9b..0d6df9c5 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.8.0 +current_version = 0.9.0-dev commit = True tag = True parse = (?P\d+)\.(?P\d+)\.(?P\d+)-?(?Pdev)? diff --git a/README.rst b/README.rst index 3fc20dd5..692f2588 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,4 @@ -ics.py `0.8.0` : iCalendar for Humans +ics.py `0.9.0-dev` : iCalendar for Humans ========================================= `Original repository `_ (GitHub) - diff --git a/doc/event.rst b/doc/event.rst index d67424ca..50227de4 100644 --- a/doc/event.rst +++ b/doc/event.rst @@ -4,7 +4,7 @@ First, let’s import the latest version of ics.py :date: >>> import ics >>> ics.__version__ - '0.8.0' + '0.9.0-dev' We’re also going to create a lot of ``datetime`` and ``timedelta`` objects, so we import them as short-hand aliases ``dt`` and ``td``: diff --git a/pyproject.toml b/pyproject.toml index 1cb62853..908ad3bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ics" -version = "0.8.0" +version = "0.9.0-dev" description = "Pythonic iCalendar (RFC 5545) Parser" authors = ["Nikita Marchant ", "Niko Fink "] license = "Apache-2.0" diff --git a/src/ics/__init__.py b/src/ics/__init__.py index f1512085..a3df764a 100644 --- a/src/ics/__init__.py +++ b/src/ics/__init__.py @@ -38,4 +38,4 @@ def load_converters(): "__version__" ] -__version__ = "0.8.0" +__version__ = "0.9.0-dev" From 07a2577cc677b0ca064664e64dc5f1a2a9f761d8 Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Sat, 11 Apr 2020 14:25:43 +0200 Subject: [PATCH 27/43] =?UTF-8?q?Bump=20version:=200.9.0-dev=20=E2=86=92?= =?UTF-8?q?=200.9.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit this was done using `bumpversion --verbose release` --- .bumpversion.cfg | 2 +- README.rst | 2 +- doc/event.rst | 2 +- pyproject.toml | 2 +- src/ics/__init__.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 0d6df9c5..38e62a69 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.9.0-dev +current_version = 0.9.0 commit = True tag = True parse = (?P\d+)\.(?P\d+)\.(?P\d+)-?(?Pdev)? diff --git a/README.rst b/README.rst index 692f2588..cee72a71 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,4 @@ -ics.py `0.9.0-dev` : iCalendar for Humans +ics.py `0.9.0` : iCalendar for Humans ========================================= `Original repository `_ (GitHub) - diff --git a/doc/event.rst b/doc/event.rst index 50227de4..9fd90d6f 100644 --- a/doc/event.rst +++ b/doc/event.rst @@ -4,7 +4,7 @@ First, let’s import the latest version of ics.py :date: >>> import ics >>> ics.__version__ - '0.9.0-dev' + '0.9.0' We’re also going to create a lot of ``datetime`` and ``timedelta`` objects, so we import them as short-hand aliases ``dt`` and ``td``: diff --git a/pyproject.toml b/pyproject.toml index 908ad3bf..70dd3207 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ics" -version = "0.9.0-dev" +version = "0.9.0" description = "Pythonic iCalendar (RFC 5545) Parser" authors = ["Nikita Marchant ", "Niko Fink "] license = "Apache-2.0" diff --git a/src/ics/__init__.py b/src/ics/__init__.py index a3df764a..71131396 100644 --- a/src/ics/__init__.py +++ b/src/ics/__init__.py @@ -38,4 +38,4 @@ def load_converters(): "__version__" ] -__version__ = "0.9.0-dev" +__version__ = "0.9.0" From aab344425b64c0246197375ba9721a129caaccf2 Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Sat, 11 Apr 2020 16:23:09 +0200 Subject: [PATCH 28/43] separate src inspection (flake8+mypy src/) from package testing (pytest tests/) to fix PATH problems --- mypy.ini | 6 -- poetry.lock | 193 +++++++++++++--------------------------------- pyproject.toml | 9 +-- tests/__init__.py | 14 +++- tox.ini | 58 +++++++++----- 5 files changed, 108 insertions(+), 172 deletions(-) delete mode 100644 mypy.ini diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index 952ed8c4..00000000 --- a/mypy.ini +++ /dev/null @@ -1,6 +0,0 @@ -[mypy] -python_version = 3.6 -warn_unused_configs = True - -[mypy-tests.*] -ignore_errors = True diff --git a/poetry.lock b/poetry.lock index 6c91a3cb..54893c6d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -6,18 +6,10 @@ optional = true python-versions = "*" version = "0.7.12" -[[package]] -category = "main" -description = "apipkg: namespace control and lazy-import mechanism" -name = "apipkg" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.5" - [[package]] category = "main" description = "Atomic file writes." -marker = "python_version >= \"3.5\" and sys_platform == \"win32\" or sys_platform == \"win32\"" +marker = "sys_platform == \"win32\"" name = "atomicwrites" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" @@ -67,7 +59,7 @@ version = "3.0.4" [[package]] category = "main" description = "Cross-platform colored terminal text." -marker = "python_version >= \"3.5\" and sys_platform == \"win32\" or sys_platform == \"win32\"" +marker = "sys_platform == \"win32\"" name = "colorama" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" @@ -94,25 +86,25 @@ version = "0.16" [[package]] category = "main" -description = "execnet: rapid multi-Python deployment" -name = "execnet" +description = "Discover and load entry points from installed packages." +name = "entrypoints" optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.7.1" - -[package.dependencies] -apipkg = ">=1.4" - -[package.extras] -testing = ["pre-commit"] +python-versions = ">=2.7" +version = "0.3" [[package]] category = "main" -description = "A platform independent file lock." -name = "filelock" +description = "the modular source code checker: pep8, pyflakes and co" +name = "flake8" optional = true -python-versions = "*" -version = "3.0.12" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "3.7.9" + +[package.dependencies] +entrypoints = ">=0.3.0,<0.4.0" +mccabe = ">=0.6.0,<0.7.0" +pycodestyle = ">=2.5.0,<2.6.0" +pyflakes = ">=2.1.0,<2.2.0" [[package]] category = "main" @@ -156,7 +148,7 @@ version = "1.2.0" [[package]] category = "main" description = "Read metadata from Python packages" -marker = "python_version >= \"3.5\" and python_version < \"3.8\" or python_version < \"3.8\"" +marker = "python_version < \"3.8\"" name = "importlib-metadata" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" @@ -211,6 +203,14 @@ optional = true python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" version = "1.1.1" +[[package]] +category = "main" +description = "McCabe checker, plugin for flake8" +name = "mccabe" +optional = true +python-versions = "*" +version = "0.6.1" + [[package]] category = "main" description = "More routines for operating on iterables, beyond itertools" @@ -255,14 +255,6 @@ version = "20.3" pyparsing = ">=2.0.2" six = "*" -[[package]] -category = "main" -description = "Python style guide checker" -name = "pep8" -optional = true -python-versions = "*" -version = "1.7.1" - [[package]] category = "main" description = "plugin and hook calling mechanisms for python" @@ -298,13 +290,21 @@ optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "1.8.1" +[[package]] +category = "main" +description = "Python style guide checker" +name = "pycodestyle" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.5.0" + [[package]] category = "main" description = "passive checker of Python programs" name = "pyflakes" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.2.0" +version = "2.1.1" [[package]] category = "main" @@ -348,18 +348,6 @@ version = ">=0.12" checkqa-mypy = ["mypy (v0.761)"] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] -[[package]] -category = "main" -description = "pytest plugin with mechanisms for caching across test runs" -name = "pytest-cache" -optional = true -python-versions = "*" -version = "1.0" - -[package.dependencies] -execnet = ">=1.1.dev1" -pytest = ">=2.2" - [[package]] category = "main" description = "Pytest plugin for measuring coverage." @@ -375,54 +363,6 @@ pytest = ">=3.6" [package.extras] testing = ["fields", "hunter", "process-tests (2.0.2)", "six", "virtualenv"] -[[package]] -category = "main" -description = "pytest plugin to check source code with pyflakes" -name = "pytest-flakes" -optional = true -python-versions = "*" -version = "4.0.0" - -[package.dependencies] -pyflakes = "*" -pytest = ">=2.8.0" - -[[package]] -category = "main" -description = "Mypy static type checker plugin for Pytest" -name = "pytest-mypy" -optional = true -python-versions = "~=3.4" -version = "0.6.1" - -[package.dependencies] -filelock = ">=3.0" - -[[package.dependencies.mypy]] -python = ">=3.5,<3.8" -version = ">=0.500" - -[[package.dependencies.mypy]] -python = ">=3.8" -version = ">=0.700" - -[package.dependencies.pytest] -python = ">=3.5" -version = ">=3.5" - -[[package]] -category = "main" -description = "pytest plugin to check PEP8 requirements" -name = "pytest-pep8" -optional = true -python-versions = "*" -version = "1.0.6" - -[package.dependencies] -pep8 = ">=1.3" -pytest = ">=2.4.2" -pytest-cache = "*" - [[package]] category = "main" description = "Extensions to the standard Python datetime module" @@ -625,17 +565,6 @@ version = "4.4.0" [package.extras] future-regex = ["regex"] -[[package]] -category = "main" -description = "TatSu takes a grammar in a variation of EBNF as input, and outputs a memoizing PEG/Packrat parser in Python." -name = "tatsu" -optional = false -python-versions = ">=3.8" -version = "5.5.0" - -[package.extras] -future-regex = ["regex"] - [[package]] category = "main" description = "a fork of Python 2 and 3 ast modules with type comment support" @@ -676,7 +605,7 @@ version = "0.1.9" [[package]] category = "main" description = "Backport of pathlib-compatible object wrapper for zip files" -marker = "python_version >= \"3.5\" and python_version < \"3.8\" or python_version < \"3.8\"" +marker = "python_version < \"3.8\"" name = "zipp" optional = false python-versions = ">=3.6" @@ -687,10 +616,10 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] [extras] -dev = ["packaging", "pytest", "pytest-cov", "pytest-flakes", "pytest-pep8", "mypy", "pytest-mypy", "hypothesis", "sphinx", "sphinxcontrib-napoleon", "sphinx-autodoc-typehints"] +dev = ["pytest", "pytest-cov", "hypothesis", "mypy", "flake8", "sphinx", "sphinxcontrib-napoleon", "sphinx-autodoc-typehints"] [metadata] -content-hash = "a41d4283c02d5f183761e69a982064900359aa26c1993bc9adb421ead2ebe22f" +content-hash = "883117c6dbfa3234b077a54d07edc062bb5ffdd8bc65248d7352583ccdf6482e" python-versions = "^3.6" [metadata.files] @@ -698,10 +627,6 @@ alabaster = [ {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"}, {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, ] -apipkg = [ - {file = "apipkg-1.5-py2.py3-none-any.whl", hash = "sha256:58587dd4dc3daefad0487f6d9ae32b4542b185e1c36db6993290e7c41ca2b47c"}, - {file = "apipkg-1.5.tar.gz", hash = "sha256:37228cda29411948b422fae072f57e31d3396d2ee1c9783775980ee9c9990af6"}, -] atomicwrites = [ {file = "atomicwrites-1.3.0-py2.py3-none-any.whl", hash = "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4"}, {file = "atomicwrites-1.3.0.tar.gz", hash = "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"}, @@ -763,13 +688,13 @@ docutils = [ {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"}, {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, ] -execnet = [ - {file = "execnet-1.7.1-py2.py3-none-any.whl", hash = "sha256:d4efd397930c46415f62f8a31388d6be4f27a91d7550eb79bc64a756e0056547"}, - {file = "execnet-1.7.1.tar.gz", hash = "sha256:cacb9df31c9680ec5f95553976c4da484d407e85e41c83cb812aa014f0eddc50"}, +entrypoints = [ + {file = "entrypoints-0.3-py2.py3-none-any.whl", hash = "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19"}, + {file = "entrypoints-0.3.tar.gz", hash = "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"}, ] -filelock = [ - {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, - {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, +flake8 = [ + {file = "flake8-3.7.9-py2.py3-none-any.whl", hash = "sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca"}, + {file = "flake8-3.7.9.tar.gz", hash = "sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb"}, ] hypothesis = [ {file = "hypothesis-5.8.0-py3-none-any.whl", hash = "sha256:84671369a278088f1d48f7ed2aca7975550344fa744783fe6cb84ad5f3f55ff2"}, @@ -830,6 +755,10 @@ markupsafe = [ {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, ] +mccabe = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] more-itertools = [ {file = "more-itertools-8.2.0.tar.gz", hash = "sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507"}, {file = "more_itertools-8.2.0-py3-none-any.whl", hash = "sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c"}, @@ -858,10 +787,6 @@ packaging = [ {file = "packaging-20.3-py2.py3-none-any.whl", hash = "sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752"}, {file = "packaging-20.3.tar.gz", hash = "sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3"}, ] -pep8 = [ - {file = "pep8-1.7.1-py2.py3-none-any.whl", hash = "sha256:b22cfae5db09833bb9bd7c8463b53e1a9c9b39f12e304a8d0bba729c501827ee"}, - {file = "pep8-1.7.1.tar.gz", hash = "sha256:fe249b52e20498e59e0b5c5256aa52ee99fc295b26ec9eaa85776ffdb9fe6374"}, -] pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, @@ -874,9 +799,13 @@ py = [ {file = "py-1.8.1-py2.py3-none-any.whl", hash = "sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0"}, {file = "py-1.8.1.tar.gz", hash = "sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa"}, ] +pycodestyle = [ + {file = "pycodestyle-2.5.0-py2.py3-none-any.whl", hash = "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56"}, + {file = "pycodestyle-2.5.0.tar.gz", hash = "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"}, +] pyflakes = [ - {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, - {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, + {file = "pyflakes-2.1.1-py2.py3-none-any.whl", hash = "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0"}, + {file = "pyflakes-2.1.1.tar.gz", hash = "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"}, ] pygments = [ {file = "Pygments-2.6.1-py3-none-any.whl", hash = "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324"}, @@ -890,24 +819,10 @@ pytest = [ {file = "pytest-5.4.1-py3-none-any.whl", hash = "sha256:0e5b30f5cb04e887b91b1ee519fa3d89049595f428c1db76e73bd7f17b09b172"}, {file = "pytest-5.4.1.tar.gz", hash = "sha256:84dde37075b8805f3d1f392cc47e38a0e59518fb46a431cfdaf7cf1ce805f970"}, ] -pytest-cache = [ - {file = "pytest-cache-1.0.tar.gz", hash = "sha256:be7468edd4d3d83f1e844959fd6e3fd28e77a481440a7118d430130ea31b07a9"}, -] pytest-cov = [ {file = "pytest-cov-2.8.1.tar.gz", hash = "sha256:cc6742d8bac45070217169f5f72ceee1e0e55b0221f54bcf24845972d3a47f2b"}, {file = "pytest_cov-2.8.1-py2.py3-none-any.whl", hash = "sha256:cdbdef4f870408ebdbfeb44e63e07eb18bb4619fae852f6e760645fa36172626"}, ] -pytest-flakes = [ - {file = "pytest-flakes-4.0.0.tar.gz", hash = "sha256:341964bf5760ebbdde9619f68a17d5632c674c3f6903ef66daa0a4f540b3d143"}, - {file = "pytest_flakes-4.0.0-py2.py3-none-any.whl", hash = "sha256:daaf319250eeefa8cb13b0ba78ffdda67926c4b6446a9e14f946b86d1ba6af23"}, -] -pytest-mypy = [ - {file = "pytest-mypy-0.6.1.tar.gz", hash = "sha256:f766b229b2760f99524f2c40c24e3288d4853334e560ab5b59a4ebffb2d4cb1d"}, - {file = "pytest_mypy-0.6.1-py3-none-any.whl", hash = "sha256:bb70bb64768a87dbbee250eee7932c84d1e8ccf68c4ce0651304b9598d072d6b"}, -] -pytest-pep8 = [ - {file = "pytest-pep8-1.0.6.tar.gz", hash = "sha256:032ef7e5fa3ac30f4458c73e05bb67b0f036a8a5cb418a534b3170f89f120318"}, -] python-dateutil = [ {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, @@ -971,8 +886,6 @@ sphinxcontrib-serializinghtml = [ tatsu = [ {file = "TatSu-4.4.0-py2.py3-none-any.whl", hash = "sha256:c9211eeee9a2d4c90f69879ec0b518b1aa0d9450249cb0dd181f5f5b18be0a92"}, {file = "TatSu-4.4.0.zip", hash = "sha256:80713413473a009f2081148d0f494884cabaf9d6866b71f2a68a92b6442f343d"}, - {file = "TatSu-5.5.0-py2.py3-none-any.whl", hash = "sha256:3a043490e577632a05374b5033646bbc26cbb17386df81735a569ecbd45d934b"}, - {file = "TatSu-5.5.0.zip", hash = "sha256:0adbf7189a8c4f9a882b442f7b8ed6c6ab3baae37057db0e96b6888daacffad0"}, ] typed-ast = [ {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, diff --git a/pyproject.toml b/pyproject.toml index 70dd3207..d568156f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,20 +35,17 @@ importlib_resources = "^1.4" # Tools used by tox for testing and building docs # see https://github.com/python-poetry/poetry/issues/1941#issuecomment-581602064 # [tool.poetry.dev-dependencies] -packaging = { version = ">=20.3", optional = true } pytest = { version = "^5.2", optional = true } pytest-cov = { version = "^2.8.1", optional = true } -pytest-flakes = { version = "^4.0.0", optional = true } -pytest-pep8 = { version = "^1.0.6", optional = true } -mypy = { version = ">=0.770", optional = true } -pytest-mypy = { version = "^0.6.1", optional = true } hypothesis = { version = "^5.8.0", optional = true } +mypy = { version = ">=0.770", optional = true } +flake8 = { version = "^3.7.9", optional = true } sphinx = { version = "^3.0.0", optional = true } sphinxcontrib-napoleon = { version = "^0.7", optional = true } sphinx-autodoc-typehints = { version = "^1.10.3", optional = true } [tool.poetry.extras] -dev = ["packaging", "pytest", "pytest-cov", "pytest-flakes", "pytest-pep8", "mypy", "pytest-mypy", "hypothesis", "sphinx", "sphinxcontrib-napoleon", "sphinx-autodoc-typehints"] +dev = ["pytest", "pytest-cov", "hypothesis", "mypy", "flake8", "sphinx", "sphinxcontrib-napoleon", "sphinx-autodoc-typehints"] [build-system] requires = ["poetry>=0.12"] diff --git a/tests/__init__.py b/tests/__init__.py index 1adb4885..b0433967 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,8 +1,18 @@ +import sys + import pkg_resources -from packaging.version import Version import ics def test_version_matches(): - assert Version(ics.__version__) == Version(pkg_resources.get_distribution('ics').version) + dist = pkg_resources.get_distribution('ics') + print(repr(dist), dist.__dict__, sys.path, ics.__path__) + assert len(ics.__path__) == 1 + ics_path = ics.__path__[0] + assert "/site-packages/" in ics_path and not "/src" in ics_path, \ + "ics should be imported from package not from sources '%s' for testing" % ics_path + for path in sys.path: + assert not path.endswith("/src"), \ + "Project sources should not be in PYTHONPATH when testing, conflicting entry: %s" % path + assert pkg_resources.parse_version(ics.__version__) == dist.parsed_version diff --git a/tox.ini b/tox.ini index ae42fcdb..05cfce59 100644 --- a/tox.ini +++ b/tox.ini @@ -1,12 +1,24 @@ [tox] isolated_build = true -envlist = py36, py37, py38, docs +envlist = py36, py37, py38, flake8, mypy, docs [testenv] extras = dev commands = - pytest {posargs} + pytest -V + python -c 'import sys, pkg_resources; dist = pkg_resources.get_distribution("ics"); print(repr(dist), dist.__dict__, sys.path)' + pytest --basetemp="{envtmpdir}" {posargs} + +[testenv:flake8] +commands = + flake8 --version + flake8 src/ + +[testenv:mypy] +commands = + mypy -V + mypy --config-file=tox.ini src/ [testenv:docs] commands = @@ -17,34 +29,44 @@ commands = python = 3.6: py36 3.7: py37 - 3.8: py38, docs + 3.8: py38, docs, flake8, mypy [pytest] python_files = *.py -flakes-ignore = - UnusedImport - UndefinedName - ImportStarUsed +norecursedirs = dist venv .git .hypothesis .mypy_cache .pytest_cache .tox .eggs .cache ics.egg-info +testpaths = doc tests +addopts = + --doctest-glob='*.rst' --doctest-modules + --ignore doc/conf.py + --hypothesis-show-statistics + --cov=ics --cov-report=xml + -s + +[flake8] # http://flake8.pycqa.org/en/latest/user/error-codes.html # https://pycodestyle.readthedocs.io/en/latest/intro.html#error-codes -pep8ignore = - tests/*.py ALL - doc/_themes/flask_theme_support.py ALL +ignore = + # E127 continuation line over-indented for visual indent E127 + # E128 continuation line under-indented for visual indent E128 + # E251 unexpected spaces around keyword / parameter equals E251 + # E402 module level import not at top of file E402 + # E501 line too long (82 > 79 characters) E501 + # E701 multiple statements on one line (colon) E701 + # E704 multiple statements on one line (def) E704 + # E731 do not assign a lambda expression, use a def E731 + # F401 module imported but unused F401 + # F403 ‘from module import *’ used; unable to detect undefined names F403 -norecursedirs = venv .git .eggs .cache ics.egg-info -testpaths = doc src/ics tests -addopts = --flakes --pep8 --mypy --cov=ics - --doctest-glob='*.rst' --doctest-modules - --ignore doc/conf.py - --hypothesis-show-statistics - --cov-report=xml - -s + +[mypy] +python_version = 3.6 +warn_unused_configs = True From c7f193d4b1ff683a8ab273a3ad5e8435f6c25437 Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Sat, 11 Apr 2020 16:23:19 +0200 Subject: [PATCH 29/43] bugfixes --- doc/event-cmp.rst | 4 ++-- src/ics/alarm.py | 2 +- src/ics/converter/special.py | 3 +-- src/ics/timespan.py | 1 + src/ics/valuetype/base.py | 7 ++++--- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/doc/event-cmp.rst b/doc/event-cmp.rst index b2b605bf..f532676b 100644 --- a/doc/event-cmp.rst +++ b/doc/event-cmp.rst @@ -261,8 +261,8 @@ timezone specified: >>> dt_ny = dt(2020, 2, 20, 20, 20, tzinfo=gettz("America/New York")) >>> dt_utc = dt(2020, 2, 20, 20, 20, tzinfo=tzutc()) >>> dt_local = dt(2020, 2, 20, 20, 20, tzinfo=tzlocal()) - >>> dt_local.tzinfo.tzname(dt_local), dt_local.tzinfo.utcoffset(dt_local) - ('+02', datetime.timedelta(seconds=7200)) + >>> dt_local.tzinfo.tzname(dt_local), dt_local.tzinfo.utcoffset(dt_local).total_seconds() + ('+02', 7200.0) >>> dt_utc < dt_ny True >>> dt_local < dt_utc # this always holds as tzlocal is +2:00 (i.e. European Summer Time) diff --git a/src/ics/alarm.py b/src/ics/alarm.py index 8a76c277..d74ca13c 100644 --- a/src/ics/alarm.py +++ b/src/ics/alarm.py @@ -59,7 +59,7 @@ class AudioAlarm(BaseAlarm): A calendar event VALARM with AUDIO option. """ - attach: Union[URL, bytes] = attr.ib(default="") # type: ignore + attach: Union[URL, bytes, None] = attr.ib(default=None) @property def action(self): diff --git a/src/ics/converter/special.py b/src/ics/converter/special.py index aca62652..2d96283d 100644 --- a/src/ics/converter/special.py +++ b/src/ics/converter/special.py @@ -26,8 +26,7 @@ def populate(self, component: "Component", item: ContainerItem, context: Context item = item.clone([ line for line in item if - not line.name.startswith("X-") and - not line.name == "SEQUENCE" + not line.name.startswith("X-") and not line.name == "SEQUENCE" ]) fake_file = StringIO() diff --git a/src/ics/timespan.py b/src/ics/timespan.py index 722cb3b2..b57a0f09 100644 --- a/src/ics/timespan.py +++ b/src/ics/timespan.py @@ -55,6 +55,7 @@ def normalize(self, value): # noqa return value +# using datetime.min might lead to problems when doing timezone conversions / comparisions (e.g. by substracting an 1 hour offset) CMP_DATETIME_NONE_DEFAULT = datetime(1900, 1, 1, 0, 0) CMP_NORMALIZATION = Normalization(normalize_floating=True, normalize_with_tz=False, replacement=tzlocal) diff --git a/src/ics/valuetype/base.py b/src/ics/valuetype/base.py index 280b55c2..883b84c6 100644 --- a/src/ics/valuetype/base.py +++ b/src/ics/valuetype/base.py @@ -7,14 +7,15 @@ T = TypeVar('T') -class ValueConverter(abc.ABC, Generic[T]): +class ValueConverter(Generic[T], abc.ABC): BY_NAME: Dict[str, "ValueConverter"] = {} BY_TYPE: Dict[Type, "ValueConverter"] = {} INST: "ValueConverter" def __init_subclass__(cls) -> None: - super().__init_subclass__() - if not inspect.isabstract(cls): + super(ValueConverter, cls).__init_subclass__() + # isabstract(ValueConverter) == False on python 3.6 + if not inspect.isabstract(cls) and cls.parse is not ValueConverter.parse: cls.INST = cls() ValueConverter.BY_NAME[cls.INST.ics_type] = cls.INST ValueConverter.BY_TYPE.setdefault(cls.INST.python_type, cls.INST) From 109f1ad66913de52a36b80e1f2a30f2adef85a21 Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Sat, 11 Apr 2020 16:26:57 +0200 Subject: [PATCH 30/43] =?UTF-8?q?Bump=20version:=200.9.0=20=E2=86=92=200.9?= =?UTF-8?q?.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- README.rst | 2 +- doc/event.rst | 2 +- pyproject.toml | 2 +- src/ics/__init__.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 38e62a69..0194e629 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.9.0 +current_version = 0.9.1 commit = True tag = True parse = (?P\d+)\.(?P\d+)\.(?P\d+)-?(?Pdev)? diff --git a/README.rst b/README.rst index cee72a71..09c97435 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,4 @@ -ics.py `0.9.0` : iCalendar for Humans +ics.py `0.9.1` : iCalendar for Humans ========================================= `Original repository `_ (GitHub) - diff --git a/doc/event.rst b/doc/event.rst index 9fd90d6f..26d23b69 100644 --- a/doc/event.rst +++ b/doc/event.rst @@ -4,7 +4,7 @@ First, let’s import the latest version of ics.py :date: >>> import ics >>> ics.__version__ - '0.9.0' + '0.9.1' We’re also going to create a lot of ``datetime`` and ``timedelta`` objects, so we import them as short-hand aliases ``dt`` and ``td``: diff --git a/pyproject.toml b/pyproject.toml index d568156f..77a494e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ics" -version = "0.9.0" +version = "0.9.1" description = "Pythonic iCalendar (RFC 5545) Parser" authors = ["Nikita Marchant ", "Niko Fink "] license = "Apache-2.0" diff --git a/src/ics/__init__.py b/src/ics/__init__.py index 71131396..ae1ddc23 100644 --- a/src/ics/__init__.py +++ b/src/ics/__init__.py @@ -38,4 +38,4 @@ def load_converters(): "__version__" ] -__version__ = "0.9.0" +__version__ = "0.9.1" From 4e7b5536ab00d1eb21ce5718fc1a53c7e5960bab Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Sat, 11 Apr 2020 16:31:40 +0200 Subject: [PATCH 31/43] =?UTF-8?q?Bump=20version:=200.9.1=20=E2=86=92=200.9?= =?UTF-8?q?.2-dev?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- README.rst | 2 +- doc/event.rst | 2 +- pyproject.toml | 2 +- src/ics/__init__.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 0194e629..bc21f57b 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.9.1 +current_version = 0.9.2-dev commit = True tag = True parse = (?P\d+)\.(?P\d+)\.(?P\d+)-?(?Pdev)? diff --git a/README.rst b/README.rst index 09c97435..067ecfc1 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,4 @@ -ics.py `0.9.1` : iCalendar for Humans +ics.py `0.9.2-dev` : iCalendar for Humans ========================================= `Original repository `_ (GitHub) - diff --git a/doc/event.rst b/doc/event.rst index 26d23b69..f1f26963 100644 --- a/doc/event.rst +++ b/doc/event.rst @@ -4,7 +4,7 @@ First, let’s import the latest version of ics.py :date: >>> import ics >>> ics.__version__ - '0.9.1' + '0.9.2-dev' We’re also going to create a lot of ``datetime`` and ``timedelta`` objects, so we import them as short-hand aliases ``dt`` and ``td``: diff --git a/pyproject.toml b/pyproject.toml index 77a494e2..fcb8f1f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ics" -version = "0.9.1" +version = "0.9.2-dev" description = "Pythonic iCalendar (RFC 5545) Parser" authors = ["Nikita Marchant ", "Niko Fink "] license = "Apache-2.0" diff --git a/src/ics/__init__.py b/src/ics/__init__.py index ae1ddc23..a7dd521d 100644 --- a/src/ics/__init__.py +++ b/src/ics/__init__.py @@ -38,4 +38,4 @@ def load_converters(): "__version__" ] -__version__ = "0.9.1" +__version__ = "0.9.2-dev" From 281c9f64286e9cea20678ace18f9628615e82957 Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Sat, 11 Apr 2020 21:49:13 +0200 Subject: [PATCH 32/43] remove old files --- .coveragerc | 2 - MANIFEST.in | 11 - dev/install-hooks | 7 - dev/post-checkout | 20 - dev/prepare-commit-msg | 2 - dev/release-workflow.rst | 21 - dev/requirements-doc.txt | 4 - dev/requirements-test.txt | 6 - dev/test | 5 - doc/LICENSE.rst | 1 - doc/Makefile | 177 - doc/requirements.txt | 3 - ics/__init__.py | 41 - ics/__meta__.py | 7 - ics/alarm.py | 131 - ics/attendee.py | 30 - ics/component.py | 66 - ics/converter/__init__.py | 0 ics/converter/base.py | 206 - ics/converter/component.py | 102 - ics/converter/special.py | 115 - ics/converter/timespan.py | 125 - ics/converter/value.py | 139 - ics/event.py | 257 - ics/geo.py | 26 - ics/grammar/__init__.py | 296 -- ics/grammar/contentline.ebnf | 37 - ics/icalendar.py | 113 - ics/timeline.py | 145 - ics/timespan.py | 439 -- ics/todo.py | 82 - ics/types.py | 176 - ics/utils.py | 228 - ics/valuetype/__init__.py | 0 ics/valuetype/base.py | 51 - ics/valuetype/datetime.py | 294 -- ics/valuetype/generic.py | 144 - ics/valuetype/special.py | 25 - ics/valuetype/text.py | 68 - mypy.ini | 3 - pyproject.toml | 2 +- requirements.txt | 4 - setup.cfg | 47 - setup.py | 73 - src/ics/__init__.py | 2 +- src/ics/converter/special.py | 3 +- tests/alarm.py | 229 - tests/calendar.py | 233 - tests/component.py | 235 - tests/duration.py | 17 - tests/event.py | 570 --- tests/fixture.py | 766 --- tests/fixtures/README | 35 - tests/fixtures/Romeo-and-Juliet.ics | 556 --- tests/fixtures/Romeo-and-Juliet.txt | 1746 ------- tests/fixtures/case_meetup.ics | 78 - tests/fixtures/encoding.ics | 16 - tests/fixtures/groupscheduled.ics | 36 - tests/fixtures/multiple.ics | 80 - tests/fixtures/recurrence.ics | 12 - tests/fixtures/small.ics | 25 - tests/fixtures/spaces.ics | 39 - tests/fixtures/time.ics | 3 - tests/fixtures/timezoned.ics | 36 - tests/fixtures/utf-8-emoji.ics | 6823 --------------------------- tests/gehol/BA1.ics | 1636 ------- tests/gehol/BA2.ics | 2239 --------- tests/gehol/BA3.ics | 1951 -------- tests/gehol/MA1.ics | 1510 ------ tests/gehol/MA2.ics | 196 - tests/misc.py | 57 - tests/parse.py | 76 - tests/test.py | 21 - tests/timeline.py | 126 - tests/timespan.py | 121 - tests/todo.py | 357 -- tests/unfold_lines.py | 62 - tests/utils.py | 89 - 78 files changed, 4 insertions(+), 23708 deletions(-) delete mode 100644 .coveragerc delete mode 100644 MANIFEST.in delete mode 100755 dev/install-hooks delete mode 100755 dev/post-checkout delete mode 100755 dev/prepare-commit-msg delete mode 100644 dev/release-workflow.rst delete mode 100644 dev/requirements-doc.txt delete mode 100644 dev/requirements-test.txt delete mode 100755 dev/test delete mode 120000 doc/LICENSE.rst delete mode 100644 doc/Makefile delete mode 100644 doc/requirements.txt delete mode 100644 ics/__init__.py delete mode 100644 ics/__meta__.py delete mode 100644 ics/alarm.py delete mode 100644 ics/attendee.py delete mode 100644 ics/component.py delete mode 100644 ics/converter/__init__.py delete mode 100644 ics/converter/base.py delete mode 100644 ics/converter/component.py delete mode 100644 ics/converter/special.py delete mode 100644 ics/converter/timespan.py delete mode 100644 ics/converter/value.py delete mode 100644 ics/event.py delete mode 100644 ics/geo.py delete mode 100644 ics/grammar/__init__.py delete mode 100644 ics/grammar/contentline.ebnf delete mode 100644 ics/icalendar.py delete mode 100644 ics/timeline.py delete mode 100644 ics/timespan.py delete mode 100644 ics/todo.py delete mode 100644 ics/types.py delete mode 100644 ics/utils.py delete mode 100644 ics/valuetype/__init__.py delete mode 100644 ics/valuetype/base.py delete mode 100644 ics/valuetype/datetime.py delete mode 100644 ics/valuetype/generic.py delete mode 100644 ics/valuetype/special.py delete mode 100644 ics/valuetype/text.py delete mode 100644 mypy.ini delete mode 100644 requirements.txt delete mode 100644 setup.cfg delete mode 100755 setup.py delete mode 100644 tests/alarm.py delete mode 100644 tests/calendar.py delete mode 100644 tests/component.py delete mode 100644 tests/duration.py delete mode 100644 tests/event.py delete mode 100644 tests/fixture.py delete mode 100644 tests/fixtures/README delete mode 100644 tests/fixtures/Romeo-and-Juliet.ics delete mode 100644 tests/fixtures/Romeo-and-Juliet.txt delete mode 100644 tests/fixtures/case_meetup.ics delete mode 100644 tests/fixtures/encoding.ics delete mode 100644 tests/fixtures/groupscheduled.ics delete mode 100644 tests/fixtures/multiple.ics delete mode 100644 tests/fixtures/recurrence.ics delete mode 100644 tests/fixtures/small.ics delete mode 100644 tests/fixtures/spaces.ics delete mode 100644 tests/fixtures/time.ics delete mode 100644 tests/fixtures/timezoned.ics delete mode 100644 tests/fixtures/utf-8-emoji.ics delete mode 100644 tests/gehol/BA1.ics delete mode 100644 tests/gehol/BA2.ics delete mode 100644 tests/gehol/BA3.ics delete mode 100644 tests/gehol/MA1.ics delete mode 100644 tests/gehol/MA2.ics delete mode 100644 tests/misc.py delete mode 100644 tests/parse.py delete mode 100644 tests/test.py delete mode 100644 tests/timeline.py delete mode 100644 tests/timespan.py delete mode 100644 tests/todo.py delete mode 100644 tests/unfold_lines.py delete mode 100644 tests/utils.py diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index d339f15a..00000000 --- a/.coveragerc +++ /dev/null @@ -1,2 +0,0 @@ -[run] -omit = ics/tools.py, ics/__init__.py, ics/__meta__.py, ics/alarm/__init__.py, ics/types.py diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 08a5d803..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,11 +0,0 @@ -include *.rst -recursive-include ics *.py -recursive-include ics *.ebnf - -recursive-include logo *.png - -include mypy.ini -include meta.py - -recursive-include tests *.py -recursive-include tests *.ics diff --git a/dev/install-hooks b/dev/install-hooks deleted file mode 100755 index 7a2d79a5..00000000 --- a/dev/install-hooks +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -pushd ./$(git rev-parse --show-cdup) > /dev/null -for file in "post-checkout" "prepare-commit-msg"; do - ln -s "../../dev/$file" ".git/hooks/$file" -done -popd > /dev/null diff --git a/dev/post-checkout b/dev/post-checkout deleted file mode 100755 index fe5e831e..00000000 --- a/dev/post-checkout +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash - -EXCLUDE="^./ve\|^./ve3|^.git/" -# Delete .pyc files and empty directories from root of project -cd ./$(git rev-parse --show-cdup) - -# Clean-up -find . -name ".DS_Store" -delete - -NUM_PYC_FILES=$( find . -name "*.pyc" | grep -v "$EXCLUDE" |wc -l | tr -d ' ' ) -if [ $NUM_PYC_FILES -gt 0 ]; then - find . -name "*.pyc" -delete - printf "\e[00;31mDeleted $NUM_PYC_FILES .pyc files\e[00m\n" -fi - -NUM_EMPTY_DIRS=$( find . -type d -empty | grep -v "$EXCLUDE" | wc -l | tr -d ' ' ) -if [ $NUM_EMPTY_DIRS -gt 0 ]; then - find . -type d -empty -delete - printf "\e[00;31mDeleted $NUM_EMPTY_DIRS empty directories\e[00m\n" -fi \ No newline at end of file diff --git a/dev/prepare-commit-msg b/dev/prepare-commit-msg deleted file mode 100755 index 13f47935..00000000 --- a/dev/prepare-commit-msg +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh - diff --git a/dev/release-workflow.rst b/dev/release-workflow.rst deleted file mode 100644 index 4eb59325..00000000 --- a/dev/release-workflow.rst +++ /dev/null @@ -1,21 +0,0 @@ -Release HOWTO -============= - -* Update CHANGELOG -* Bump version -* pyroma . -* check-manifest -* `rm dist/*` -* `python3 setup.py egg_info bdist_egg bdist_wheel` -* Test the packages in dist/ -* Upload `twine upload dist/*` -* Check PyPI release page for obvious errors -* `git commit` -* `git tag -a v{version} -m 'Version {version}'` -* Start the new changlog -* Set version to "{version+1}-dev" -* `git commit` -* `git push --tags && git push` -* Update the release on GitHub with the changelog -* Build documentation for the tag v{version} on rtfd.org -* Set the default rtfd version to {version} diff --git a/dev/requirements-doc.txt b/dev/requirements-doc.txt deleted file mode 100644 index 780ae4c7..00000000 --- a/dev/requirements-doc.txt +++ /dev/null @@ -1,4 +0,0 @@ -sphinx -sphinxcontrib-napoleon -sphinx-autodoc-typehints --r ../requirements.txt diff --git a/dev/requirements-test.txt b/dev/requirements-test.txt deleted file mode 100644 index 6377d0a3..00000000 --- a/dev/requirements-test.txt +++ /dev/null @@ -1,6 +0,0 @@ -pytest -pytest-cov -pytest-flakes -pytest-pep8 -pytest-sugar -mypy>=0.770 diff --git a/dev/test b/dev/test deleted file mode 100755 index 33171128..00000000 --- a/dev/test +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -source ve3/bin/activate -python setup.py test -deactivate diff --git a/doc/LICENSE.rst b/doc/LICENSE.rst deleted file mode 120000 index 19cdae75..00000000 --- a/doc/LICENSE.rst +++ /dev/null @@ -1 +0,0 @@ -../LICENSE.rst \ No newline at end of file diff --git a/doc/Makefile b/doc/Makefile deleted file mode 100644 index ee9b3115..00000000 --- a/doc/Makefile +++ /dev/null @@ -1,177 +0,0 @@ -# Makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = -BUILDDIR = _build - -# User-friendly check for sphinx-build -ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) -$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) -endif - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . - -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext - -help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " xml to make Docutils-native XML files" - @echo " pseudoxml to make pseudoxml-XML files for display purposes" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - -clean: - rm -rf $(BUILDDIR)/* - -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/icspy.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/icspy.qhc" - -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/icspy" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/icspy" - @echo "# devhelp" - -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -latexpdfja: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through platex and dvipdfmx..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." - -xml: - $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml - @echo - @echo "Build finished. The XML files are in $(BUILDDIR)/xml." - -pseudoxml: - $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml - @echo - @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/doc/requirements.txt b/doc/requirements.txt deleted file mode 100644 index 78c8bcfe..00000000 --- a/doc/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -sphinx -sphinxcontrib-napoleon -sphinx-autodoc-types diff --git a/ics/__init__.py b/ics/__init__.py deleted file mode 100644 index 50a62f94..00000000 --- a/ics/__init__.py +++ /dev/null @@ -1,41 +0,0 @@ -def load_converters(): - from ics.converter.base import AttributeConverter - from ics.converter.component import ComponentConverter - from ics.converter.special import TimezoneConverter, AlarmConverter, PersonConverter, RecurrenceConverter - from ics.converter.timespan import TimespanConverter - from ics.converter.value import AttributeValueConverter - from ics.valuetype.base import ValueConverter - from ics.valuetype.datetime import DateConverter, DatetimeConverter, DurationConverter, PeriodConverter, TimeConverter, UTCOffsetConverter - from ics.valuetype.generic import BinaryConverter, BooleanConverter, CalendarUserAddressConverter, FloatConverter, IntegerConverter, RecurConverter, URIConverter - from ics.valuetype.text import TextConverter - from ics.valuetype.special import GeoConverter - - -load_converters() # make sure that converters are initialized before any Component classes are defined - -from .__meta__ import * # noqa -from .__meta__ import __all__ as all_meta -from .alarm import * # noqa -from .alarm import __all__ as all_alarms -from .attendee import Attendee, Organizer -from .component import Component -from .event import Event -from .geo import Geo -from .grammar import Container, ContentLine -from .icalendar import Calendar -from .timespan import EventTimespan, Timespan, TodoTimespan -from .todo import Todo - -__all__ = [ - *all_meta, - *all_alarms, - "Attendee", - "Event", - "Calendar", - "Organizer", - "Timespan", - "EventTimespan", - "TodoTimespan", - "Todo", - "Component" -] diff --git a/ics/__meta__.py b/ics/__meta__.py deleted file mode 100644 index fec9635b..00000000 --- a/ics/__meta__.py +++ /dev/null @@ -1,7 +0,0 @@ -__title__ = "ics" -__version__ = "0.8dev" -__author__ = "Nikita Marchant" -__license__ = "Apache License, Version 2.0" -__copyright__ = "Copyright 2013-2020 Nikita Marchant and individual contributors" - -__all__ = ["__title__", "__version__", "__author__", "__license__", "__copyright__"] diff --git a/ics/alarm.py b/ics/alarm.py deleted file mode 100644 index 71f5509d..00000000 --- a/ics/alarm.py +++ /dev/null @@ -1,131 +0,0 @@ -from abc import ABCMeta, abstractmethod -from datetime import datetime, timedelta -from typing import List, Union - -import attr -from attr.converters import optional as c_optional -from attr.validators import instance_of, optional as v_optional - -from ics.attendee import Attendee -from ics.component import Component -from ics.converter.component import ComponentMeta -from ics.converter.special import AlarmConverter -from ics.types import URL -from ics.utils import call_validate_on_inst, check_is_instance, ensure_timedelta - -__all__ = ["BaseAlarm", "AudioAlarm", "CustomAlarm", "DisplayAlarm", "EmailAlarm", "NoneAlarm"] - - -@attr.s -class BaseAlarm(Component, metaclass=ABCMeta): - """ - A calendar event VALARM base class - """ - Meta = ComponentMeta("VALARM", converter_class=AlarmConverter) - - trigger: Union[timedelta, datetime, None] = attr.ib( - default=None, - validator=v_optional(instance_of((timedelta, datetime))) # type: ignore - ) # TODO is this relative to begin or end? - repeat: int = attr.ib(default=None, validator=call_validate_on_inst) - duration: timedelta = attr.ib(default=None, converter=c_optional(ensure_timedelta), validator=call_validate_on_inst) # type: ignore - - def validate(self, attr=None, value=None): - if self.repeat is not None: - if self.repeat < 0: - raise ValueError("Repeat must be great than or equal to 0.") - if self.duration is None: - raise ValueError( - "A definition of an alarm with a repeating trigger MUST include both the DURATION and REPEAT properties." - ) - - if self.duration is not None and self.duration.total_seconds() < 0: - raise ValueError("Alarm duration timespan must be positive.") - - @property - @abstractmethod - def action(self): - """ VALARM action to be implemented by concrete classes """ - pass - - -@attr.s -class AudioAlarm(BaseAlarm): - """ - A calendar event VALARM with AUDIO option. - """ - - attach: Union[URL, bytes, None] = attr.ib(default=None) - - @property - def action(self): - return "AUDIO" - - -@attr.s -class CustomAlarm(BaseAlarm): - """ - A calendar event VALARM with custom ACTION. - """ - - _action = attr.ib(default=None) - - @property - def action(self): - return self._action - - -@attr.s -class DisplayAlarm(BaseAlarm): - """ - A calendar event VALARM with DISPLAY option. - """ - - display_text: str = attr.ib(default=None) - - @property - def action(self): - return "DISPLAY" - - -@attr.s -class EmailAlarm(BaseAlarm): - """ - A calendar event VALARM with Email option. - """ - - subject: str = attr.ib(default=None) - body: str = attr.ib(default=None) - recipients: List[Attendee] = attr.ib(factory=list) - - def add_recipient(self, recipient: Attendee): - """ Add an recipient to the recipients list """ - check_is_instance("recipient", recipient, Attendee) - self.recipients.append(recipient) - - @property - def action(self): - return "EMAIL" - - -class NoneAlarm(BaseAlarm): - """ - A calendar event VALARM with NONE option. - """ - - @property - def action(self): - return "NONE" - - -def get_type_from_action(action_type): - if action_type == "DISPLAY": - return DisplayAlarm - elif action_type == "AUDIO": - return AudioAlarm - elif action_type == "NONE": - return NoneAlarm - elif action_type == "EMAIL": - return EmailAlarm - else: - return CustomAlarm diff --git a/ics/attendee.py b/ics/attendee.py deleted file mode 100644 index 330bc50c..00000000 --- a/ics/attendee.py +++ /dev/null @@ -1,30 +0,0 @@ -from typing import Dict, List, Optional - -import attr - -from ics.converter.component import ComponentMeta - - -@attr.s -class Person(object): - email: str = attr.ib() - common_name: str = attr.ib(default=None) - dir: Optional[str] = attr.ib(default=None) - sent_by: Optional[str] = attr.ib(default=None) - extra: Dict[str, List[str]] = attr.ib(factory=dict) - - Meta = ComponentMeta("ABSTRACT-PERSON") - - -class Organizer(Person): - Meta = ComponentMeta("ORGANIZER") - - -@attr.s -class Attendee(Person): - rsvp: Optional[bool] = attr.ib(default=None) - role: Optional[str] = attr.ib(default=None) - partstat: Optional[str] = attr.ib(default=None) - cutype: Optional[str] = attr.ib(default=None) - - Meta = ComponentMeta("ATTENDEE") diff --git a/ics/component.py b/ics/component.py deleted file mode 100644 index 6c52635b..00000000 --- a/ics/component.py +++ /dev/null @@ -1,66 +0,0 @@ -from typing import ClassVar, Dict, List, Type, TypeVar, Union - -import attr -from attr.validators import instance_of - -from ics.converter.component import ComponentMeta, InflatedComponentMeta -from ics.grammar import Container -from ics.types import ExtraParams, RuntimeAttrValidation - -PLACEHOLDER_CONTAINER = Container("PLACEHOLDER") -ComponentType = TypeVar('ComponentType', bound='Component') -ComponentExtraParams = Dict[str, Union[ExtraParams, List[ExtraParams]]] - - -@attr.s -class Component(RuntimeAttrValidation): - Meta: ClassVar[Union[ComponentMeta, InflatedComponentMeta]] = ComponentMeta("ABSTRACT-COMPONENT") - - extra: Container = attr.ib(init=False, default=PLACEHOLDER_CONTAINER, validator=instance_of(Container), metadata={"ics_ignore": True}) - extra_params: ComponentExtraParams = attr.ib(init=False, factory=dict, validator=instance_of(dict), metadata={"ics_ignore": True}) - - def __attrs_post_init__(self): - super(Component, self).__attrs_post_init__() - if self.extra is PLACEHOLDER_CONTAINER: - self.extra = Container(self.Meta.container_name) - - def __init_subclass__(cls): - super().__init_subclass__() - cls.Meta.inflate(cls) - - @classmethod - def from_container(cls: Type[ComponentType], container: Container) -> ComponentType: - return cls.Meta.load_instance(container) # type: ignore - - def populate(self, container: Container): - self.Meta.populate_instance(self, container) # type: ignore - - def to_container(self) -> Container: - return self.Meta.serialize_toplevel(self) # type: ignore - - def serialize(self) -> str: - return self.to_container().serialize() - - def strip_extras(self, all_extras=False, extra_properties=None, extra_params=None, property_merging=None): - if extra_properties is None: - extra_properties = all_extras - if extra_params is None: - extra_params = all_extras - if property_merging is None: - property_merging = all_extras - if not any([extra_properties, extra_params, property_merging]): - raise ValueError("need to strip at least one thing") - if extra_properties: - self.extra.clear() - if extra_params: - self.extra_params.clear() - elif property_merging: - for val in self.extra_params.values(): - if not isinstance(val, list): continue - for v in val: - v.pop("__merge_next", None) - - def clone(self): - """Returns an exact (shallow) copy of self""" - # TODO deep copies? - return attr.evolve(self) diff --git a/ics/converter/__init__.py b/ics/converter/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ics/converter/base.py b/ics/converter/base.py deleted file mode 100644 index cd9cf90e..00000000 --- a/ics/converter/base.py +++ /dev/null @@ -1,206 +0,0 @@ -import abc -import warnings -from typing import Any, ClassVar, Dict, List, MutableSequence, Optional, TYPE_CHECKING, Tuple, Type, Union, cast - -import attr - -from ics.grammar import Container -from ics.types import ContainerItem, ContextDict, ExtraParams - -if TYPE_CHECKING: - from ics.component import Component - from ics.converter.component import InflatedComponentMeta - -NoneTypes = [type(None), None] - - -# TODO make validation / ValueError / warnings configurable -# TODO use repr for warning messages and ensure that they don't get to long - -class GenericConverter(abc.ABC): - @property - @abc.abstractmethod - def priority(self) -> int: - pass - - @property - @abc.abstractmethod - def filter_ics_names(self) -> List[str]: - pass - - @abc.abstractmethod - def populate(self, component: "Component", item: ContainerItem, context: ContextDict) -> bool: - """ - :param context: - :param component: - :param item: - :return: True, if the line was consumed and shouldn't be stored as extra (but might still be passed on) - """ - pass - - def finalize(self, component: "Component", context: ContextDict): - pass - - @abc.abstractmethod - def serialize(self, component: "Component", output: Container, context: ContextDict): - pass - - -@attr.s(frozen=True) -class AttributeConverter(GenericConverter, abc.ABC): - BY_TYPE: ClassVar[Dict[Type, Type["AttributeConverter"]]] = {} - - attribute: attr.Attribute = attr.ib() - - multi_value_type: Optional[Type[MutableSequence]] - value_type: Type - value_types: List[Type] - _priority: int - is_required: bool - - def __attrs_post_init__(self): - multi_value_type, value_type, value_types = extract_attr_type(self.attribute) - _priority = self.attribute.metadata.get("ics_priority", self.default_priority) - is_required = self.attribute.metadata.get("ics_required", None) - if is_required is None: - if not self.attribute.init: - is_required = False - elif self.attribute.default is not attr.NOTHING: - is_required = False - else: - is_required = True - for key, value in locals().items(): # all variables created in __attrs_post_init__ will be set on self - if key == "self" or key.startswith("__"): continue - object.__setattr__(self, key, value) - - def _check_component(self, component: "Component", context: ContextDict): - if context[(self, "current_component")] is None: - context[(self, "current_component")] = component - context[(self, "current_value_count")] = 0 - else: - if context[(self, "current_component")] is not component: - raise ValueError("must call finalize before call to populate with another component") - - def finalize(self, component: "Component", context: ContextDict): - context[(self, "current_component")] = None - context[(self, "current_value_count")] = 0 - - def set_or_append_value(self, component: "Component", value: Any): - if self.multi_value_type is not None: - container = getattr(component, self.attribute.name) - if container is None: - container = self.multi_value_type() - setattr(component, self.attribute.name, container) - container.append(value) - else: - setattr(component, self.attribute.name, value) - - def get_value(self, component: "Component") -> Any: - return getattr(component, self.attribute.name) - - def get_value_list(self, component: "Component") -> List[Any]: - if self.is_multi_value: - return list(self.get_value(component)) - else: - return [self.get_value(component)] - - def set_or_append_extra_params(self, component: "Component", value: ExtraParams, name: Optional[str] = None): - name = name or self.attribute.name - if self.is_multi_value: - extras = component.extra_params.setdefault(name, []) - cast(List[ExtraParams], extras).append(value) - elif value: - component.extra_params[name] = value - - def get_extra_params(self, component: "Component", name: Optional[str] = None) -> Union[ExtraParams, List[ExtraParams]]: - if self.multi_value_type: - default: Union[ExtraParams, List[ExtraParams]] = cast(List[ExtraParams], list()) - else: - default = ExtraParams(dict()) - name = name or self.attribute.name - return component.extra_params.get(name, default) - - @property - def default_priority(self) -> int: - return 0 - - @property - def priority(self) -> int: - return self._priority - - @property - def is_multi_value(self) -> bool: - return self.multi_value_type is not None - - @staticmethod - def get_converter_for(attribute: attr.Attribute) -> Optional["AttributeConverter"]: - if attribute.metadata.get("ics_ignore", not attribute.init): - return None - converter = attribute.metadata.get("ics_converter", None) - if converter: - return converter(attribute) - - multi_value_type, value_type, value_types = extract_attr_type(attribute) - if len(value_types) == 1: - assert [value_type] == value_types - from ics.component import Component - if issubclass(value_type, Component): - meta: "InflatedComponentMeta" = cast("InflatedComponentMeta", value_type.Meta) - return meta(attribute) - elif value_type in AttributeConverter.BY_TYPE: - return AttributeConverter.BY_TYPE[value_type](attribute) - - from ics.converter.value import AttributeValueConverter - return AttributeValueConverter(attribute) - - -def extract_attr_type(attribute: attr.Attribute) -> Tuple[Optional[Type[MutableSequence]], Type, List[Type]]: - attr_type = attribute.metadata.get("ics_type", attribute.type) - if attr_type is None: - raise ValueError("can't convert attribute %s with AttributeConverter, " - "as it has no type information" % attribute) - return unwrap_type(attr_type) - - -def unwrap_type(attr_type: Type) -> Tuple[Optional[Type[MutableSequence]], Type, List[Type]]: - generic_origin = getattr(attr_type, "__origin__", attr_type) - generic_vars = getattr(attr_type, "__args__", tuple()) - - if generic_origin == Union: - generic_vars = [v for v in generic_vars if v not in NoneTypes] - if len(generic_vars) > 1: - return None, generic_origin[tuple(generic_vars)], list(generic_vars) - else: - return None, generic_vars[0], [generic_vars[0]] - - elif issubclass(generic_origin, MutableSequence): - if len(generic_vars) > 1: - warnings.warn("using first parameter for List type %s" % attr_type) - res = unwrap_type(generic_vars[0]) - assert res[0] is None - return generic_origin, res[1], res[2] - - else: - return None, attr_type, [attr_type] - - -def ics_attr_meta(name: str = None, - ignore: bool = None, - type: Type = None, - required: bool = None, - priority: int = None, - converter: Type[AttributeConverter] = None) -> Dict[str, Any]: - data: Dict[str, Any] = {} - if name: - data["ics_name"] = name - if ignore is not None: - data["ics_ignore"] = ignore - if type is not None: - data["ics_type"] = type - if required is not None: - data["ics_required"] = required - if priority is not None: - data["ics_priority"] = priority - if converter is not None: - data["ics_converter"] = converter - return data diff --git a/ics/converter/component.py b/ics/converter/component.py deleted file mode 100644 index 1baeadba..00000000 --- a/ics/converter/component.py +++ /dev/null @@ -1,102 +0,0 @@ -from collections import defaultdict -from typing import Dict, Iterable, List, Optional, TYPE_CHECKING, Tuple, Type, cast - -import attr -from attr import Attribute - -from ics.converter.base import AttributeConverter, GenericConverter -from ics.grammar import Container -from ics.types import ContainerItem, ContextDict - -if TYPE_CHECKING: - from ics.component import Component - - -@attr.s(frozen=True) -class ComponentMeta(object): - container_name: str = attr.ib() - converter_class: Type["ComponentConverter"] = attr.ib(default=None) - - def inflate(self, component_type: Type["Component"]): - if component_type.Meta is not self: - raise ValueError("can't inflate %s for %s, it's meta is %s" % (self, component_type, component_type.Meta)) - converters = cast(Iterable["AttributeConverter"], filter(bool, ( - AttributeConverter.get_converter_for(a) - for a in attr.fields(component_type) - ))) - component_type.Meta = InflatedComponentMeta( - component_type=component_type, - converters=tuple(sorted(converters, key=lambda c: c.priority)), - container_name=self.container_name, - converter_class=self.converter_class or ComponentConverter) - - -@attr.s(frozen=True) -class InflatedComponentMeta(ComponentMeta): - converters: Tuple[GenericConverter, ...] = attr.ib(default=None) - component_type: Type["Component"] = attr.ib(default=None) - - converter_lookup: Dict[str, List[GenericConverter]] - - def __attrs_post_init__(self): - object.__setattr__(self, "converter_lookup", defaultdict(list)) - for converter in self.converters: - for name in converter.filter_ics_names: - self.converter_lookup[name].append(converter) - - def __call__(self, attribute: Attribute): - return self.converter_class(attribute, self) - - def load_instance(self, container: Container, context: Optional[ContextDict] = None): - instance = self.component_type() - self.populate_instance(instance, container, context) - return instance - - def populate_instance(self, instance: "Component", container: Container, context: Optional[ContextDict] = None): - if container.name != self.container_name: - raise ValueError("container isn't an {}".format(self.container_name)) - if not context: - context = ContextDict(defaultdict(lambda: None)) - - for line in container: - consumed = False - for conv in self.converter_lookup[line.name]: - if conv.populate(instance, line, context): - consumed = True - if not consumed: - instance.extra.append(line) - - for conv in self.converters: - conv.finalize(instance, context) - - def serialize_toplevel(self, component: "Component", context: Optional[ContextDict] = None): - if not context: - context = ContextDict(defaultdict(lambda: None)) - container = Container(self.container_name) - for conv in self.converters: - conv.serialize(component, container, context) - container.extend(component.extra) - return container - - -@attr.s(frozen=True) -class ComponentConverter(AttributeConverter): - meta: InflatedComponentMeta = attr.ib() - - @property - def filter_ics_names(self) -> List[str]: - return [self.meta.container_name] - - def populate(self, component: "Component", item: ContainerItem, context: ContextDict) -> bool: - assert isinstance(item, Container) - self._check_component(component, context) - self.set_or_append_value(component, self.meta.load_instance(item, context)) - return True - - def serialize(self, parent: "Component", output: Container, context: ContextDict): - self._check_component(parent, context) - extras = self.get_extra_params(parent) - if extras: - raise ValueError("ComponentConverter %s can't serialize extra params %s", (self, extras)) - for value in self.get_value_list(parent): - output.append(self.meta.serialize_toplevel(value, context)) diff --git a/ics/converter/special.py b/ics/converter/special.py deleted file mode 100644 index e36be381..00000000 --- a/ics/converter/special.py +++ /dev/null @@ -1,115 +0,0 @@ -from datetime import tzinfo -from io import StringIO -from typing import List, TYPE_CHECKING - -from dateutil.rrule import rruleset -from dateutil.tz import tzical - -from ics.attendee import Attendee, Organizer, Person -from ics.converter.base import AttributeConverter -from ics.converter.component import ComponentConverter -from ics.grammar import Container, ContentLine -from ics.types import ContainerItem, ContextDict - -if TYPE_CHECKING: - from ics.component import Component - - -class TimezoneConverter(AttributeConverter): - @property - def filter_ics_names(self) -> List[str]: - return ["VTIMEZONE"] - - def populate(self, component: "Component", item: ContainerItem, context: ContextDict) -> bool: - assert isinstance(item, Container) - self._check_component(component, context) - - item = item.clone([ - line for line in item if - not line.name.startswith("X-") and - not line.name == "SEQUENCE" - ]) - - fake_file = StringIO() - fake_file.write(item.serialize()) # Represent the block as a string - fake_file.seek(0) - timezones = tzical(fake_file) # tzical does not like strings - - # timezones is a tzical object and could contain multiple timezones - print("got timezone", timezones.keys(), timezones.get()) - return True - - def serialize(self, component: "Component", output: Container, context: ContextDict): - raise NotImplementedError("Timezones can't be serialized") - - -AttributeConverter.BY_TYPE[tzinfo] = TimezoneConverter - - -class RecurrenceConverter(AttributeConverter): - # TODO handle extras? - # TODO pass and handle available_tz / tzinfos - - @property - def filter_ics_names(self) -> List[str]: - return ["RRULE", "RDATE", "EXRULE", "EXDATE", "DTSTART"] - - def populate(self, component: "Component", item: ContainerItem, context: ContextDict) -> bool: - assert isinstance(item, ContentLine) - self._check_component(component, context) - # self.lines.append(item) - return False - - def finalize(self, component: "Component", context: ContextDict): - self._check_component(component, context) - # rrulestr("\r\n".join(self.lines), tzinfos={}, compatible=True) - - def serialize(self, component: "Component", output: Container, context: ContextDict): - pass - # value = rruleset() - # for rrule in value._rrule: - # output.append(ContentLine("RRULE", value=re.match("^RRULE:(.*)$", str(rrule)).group(1))) - # for exrule in value._exrule: - # output.append(ContentLine("EXRULE", value=re.match("^RRULE:(.*)$", str(exrule)).group(1))) - # for rdate in value._rdate: - # output.append(ContentLine(name="RDATE", value=DatetimeConverter.INST.serialize(rdate))) - # for exdate in value._exdate: - # output.append(ContentLine(name="EXDATE", value=DatetimeConverter.INST.serialize(exdate))) - - -AttributeConverter.BY_TYPE[rruleset] = RecurrenceConverter - - -class PersonConverter(AttributeConverter): - # TODO handle lists - - @property - def filter_ics_names(self) -> List[str]: - return [] - - def populate(self, component: "Component", item: ContainerItem, context: ContextDict) -> bool: - assert isinstance(item, ContentLine) - self._check_component(component, context) - return False - - def serialize(self, component: "Component", output: Container, context: ContextDict): - pass - - -AttributeConverter.BY_TYPE[Person] = PersonConverter -AttributeConverter.BY_TYPE[Attendee] = PersonConverter -AttributeConverter.BY_TYPE[Organizer] = PersonConverter - - -class AlarmConverter(ComponentConverter): - def populate(self, component: "Component", item: ContainerItem, context: ContextDict) -> bool: - # TODO handle trigger: Union[timedelta, datetime, None] before duration - assert isinstance(item, Container) - self._check_component(component, context) - - from ics.alarm import get_type_from_action - alarm_type = get_type_from_action(item) - instance = alarm_type() - alarm_type.Meta.populate_instance(instance, item, context) - self.set_or_append_value(component, instance) - return True diff --git a/ics/converter/timespan.py b/ics/converter/timespan.py deleted file mode 100644 index 74592ed8..00000000 --- a/ics/converter/timespan.py +++ /dev/null @@ -1,125 +0,0 @@ -from typing import List, TYPE_CHECKING, cast - -from ics.converter.base import AttributeConverter -from ics.grammar import Container, ContentLine -from ics.timespan import EventTimespan, Timespan, TodoTimespan -from ics.types import ContainerItem, ContextDict, ExtraParams, copy_extra_params -from ics.utils import ensure_datetime -from ics.valuetype.datetime import DateConverter, DatetimeConverter, DurationConverter - -if TYPE_CHECKING: - from ics.component import Component - -CONTEXT_BEGIN_TIME = "timespan_begin_time" -CONTEXT_END_TIME = "timespan_end_time" -CONTEXT_DURATION = "timespan_duration" -CONTEXT_PRECISION = "timespan_precision" -CONTEXT_END_NAME = "timespan_end_name" -CONTEXT_ITEMS = "timespan_items" -CONTEXT_KEYS = [CONTEXT_BEGIN_TIME, CONTEXT_END_TIME, CONTEXT_DURATION, - CONTEXT_PRECISION, CONTEXT_END_NAME, CONTEXT_ITEMS] - - -class TimespanConverter(AttributeConverter): - @property - def default_priority(self) -> int: - return 10000 - - @property - def filter_ics_names(self) -> List[str]: - return ["DTSTART", "DTEND", "DUE", "DURATION"] - - def populate(self, component: "Component", item: ContainerItem, context: ContextDict) -> bool: - assert isinstance(item, ContentLine) - self._check_component(component, context) - - seen_items = context.setdefault(CONTEXT_ITEMS, set()) - if item.name in seen_items: - raise ValueError("duplicate value for %s in %s" % (item.name, item)) - seen_items.add(item.name) - - params = copy_extra_params(item.params) - if item.name in ["DTSTART", "DTEND", "DUE"]: - value_type = params.pop("VALUE", ["DATE-TIME"]) - if value_type == ["DATE-TIME"]: - precision = "second" - elif value_type == ["DATE"]: - precision = "day" - else: - raise ValueError("can't handle %s with value type %s" % (item.name, value_type)) - - if context[CONTEXT_PRECISION] is None: - context[CONTEXT_PRECISION] = precision - else: - if context[CONTEXT_PRECISION] != precision: - raise ValueError("event with diverging begin and end time precision") - - if precision == "day": - value = DateConverter.INST.parse(item.value, params, context) - else: - assert precision == "second" - value = DatetimeConverter.INST.parse(item.value, params, context) - - if item.name == "DTSTART": - self.set_or_append_extra_params(component, params, name="begin") - context[CONTEXT_BEGIN_TIME] = value - else: - end_name = {"DTEND": "end", "DUE": "due"}[item.name] - context[CONTEXT_END_NAME] = end_name - self.set_or_append_extra_params(component, params, name=end_name) - context[CONTEXT_END_TIME] = value - - else: - assert item.name == "DURATION" - self.set_or_append_extra_params(component, params, name="duration") - context[CONTEXT_DURATION] = DurationConverter.INST.parse(item.value, params, context) - - return True - - def finalize(self, component: "Component", context: ContextDict): - self._check_component(component, context) - # missing values will be reported by the Timespan validator - timespan = self.value_type( - ensure_datetime(context[CONTEXT_BEGIN_TIME]), ensure_datetime(context[CONTEXT_END_TIME]), - context[CONTEXT_DURATION], context[CONTEXT_PRECISION]) - if context[CONTEXT_END_NAME] and context[CONTEXT_END_NAME] != timespan._end_name(): - raise ValueError("expected to get %s value, but got %s instead" - % (timespan._end_name(), context[CONTEXT_END_NAME])) - self.set_or_append_value(component, timespan) - super(TimespanConverter, self).finalize(component, context) - # we need to clear all values, otherwise they might not get overwritten by the next parsed Timespan - for key in CONTEXT_KEYS: - context.pop(key, None) - - def serialize(self, component: "Component", output: Container, context: ContextDict): - self._check_component(component, context) - value: Timespan = self.get_value(component) - if value.is_all_day(): - value_type = {"VALUE": ["DATE"]} - dt_conv = DateConverter.INST - else: - value_type = {} # implicit default is {"VALUE": ["DATE-TIME"]} - dt_conv = DatetimeConverter.INST - - if value.get_begin(): - params = copy_extra_params(cast(ExtraParams, self.get_extra_params(component, "begin"))) - params.update(value_type) - dt_value = dt_conv.serialize(value.get_begin(), params, context) - output.append(ContentLine(name="DTSTART", params=params, value=dt_value)) - - if value.get_end_representation() == "end": - end_name = {"end": "DTEND", "due": "DUE"}[value._end_name()] - params = copy_extra_params(cast(ExtraParams, self.get_extra_params(component, end_name))) - params.update(value_type) - dt_value = dt_conv.serialize(value.get_effective_end(), params, context) - output.append(ContentLine(name=end_name, params=params, value=dt_value)) - - elif value.get_end_representation() == "duration": - params = copy_extra_params(cast(ExtraParams, self.get_extra_params(component, "duration"))) - dur_value = DurationConverter.INST.serialize(value.get_effective_duration(), params, context) - output.append(ContentLine(name="DURATION", params=params, value=dur_value)) - - -AttributeConverter.BY_TYPE[Timespan] = TimespanConverter -AttributeConverter.BY_TYPE[EventTimespan] = TimespanConverter -AttributeConverter.BY_TYPE[TodoTimespan] = TimespanConverter diff --git a/ics/converter/value.py b/ics/converter/value.py deleted file mode 100644 index 0deba572..00000000 --- a/ics/converter/value.py +++ /dev/null @@ -1,139 +0,0 @@ -from typing import Any, List, TYPE_CHECKING, Tuple, cast - -import attr - -from ics.converter.base import AttributeConverter -from ics.grammar import Container, ContentLine -from ics.types import ContainerItem, ContextDict, ExtraParams, copy_extra_params -from ics.valuetype.base import ValueConverter - -if TYPE_CHECKING: - from ics.component import Component - - -@attr.s(frozen=True) -class AttributeValueConverter(AttributeConverter): - value_converters: List[ValueConverter] - - def __attrs_post_init__(self): - super(AttributeValueConverter, self).__attrs_post_init__() - object.__setattr__(self, "value_converters", []) - for value_type in self.value_types: - converter = ValueConverter.BY_TYPE.get(value_type, None) - if converter is None: - raise ValueError("can't convert %s with ValueConverter" % value_type) - self.value_converters.append(converter) - - @property - def filter_ics_names(self) -> List[str]: - return [self.ics_name] - - @property - def ics_name(self) -> str: - name = self.attribute.metadata.get("ics_name", None) - if not name: - name = self.attribute.name.upper().replace("_", "-").strip("-") - return name - - def __prepare_params(self, line: "ContentLine") -> Tuple[ExtraParams, ValueConverter]: - params = copy_extra_params(line.params) - value_type = params.pop("VALUE", None) - if value_type: - if len(value_type) != 1: - raise ValueError("multiple VALUE type definitions in %s" % line) - for converter in self.value_converters: - if converter.ics_type == value_type[0]: - break - else: - raise ValueError("can't convert %s with %s" % (line, self)) - else: - converter = self.value_converters[0] - return params, converter - - # TODO make storing/writing extra values/params configurably optional, but warn when information is lost - - def populate(self, component: "Component", item: ContainerItem, context: ContextDict) -> bool: - assert isinstance(item, ContentLine) - self._check_component(component, context) - if self.is_multi_value: - params, converter = self.__prepare_params(item) - for value in converter.split_value_list(item.value): - context[(self, "current_value_count")] += 1 - params = copy_extra_params(params) - parsed = converter.parse(value, params, context) # might modify params and context - params["__merge_next"] = True # type: ignore - self.set_or_append_extra_params(component, params) - self.set_or_append_value(component, parsed) - if params is not None: - params["__merge_next"] = False # type: ignore - else: - if context[(self, "current_value_count")] > 0: - raise ValueError("attribute %s can only be set once, second occurrence is %s" % (self.ics_name, item)) - context[(self, "current_value_count")] += 1 - params, converter = self.__prepare_params(item) - parsed = converter.parse(item.value, params, context) # might modify params and context - self.set_or_append_extra_params(component, params) - self.set_or_append_value(component, parsed) - return True - - def finalize(self, component: "Component", context: ContextDict): - self._check_component(component, context) - if self.is_required and context[(self, "current_value_count")] < 1: - raise ValueError("attribute %s is required but got no value" % self.ics_name) - super(AttributeValueConverter, self).finalize(component, context) - - def __find_value_converter(self, params: ExtraParams, value: Any) -> ValueConverter: - for nr, converter in enumerate(self.value_converters): - if not isinstance(value, converter.python_type): continue - if nr > 0: - params["VALUE"] = [converter.ics_type] - return converter - else: - raise ValueError("can't convert %s with %s" % (value, self)) - - def serialize(self, component: "Component", output: Container, context: ContextDict): - if self.is_multi_value: - self.__serialize_multi(component, output, context) - else: - value = self.get_value(component) - if value: - params = copy_extra_params(cast(ExtraParams, self.get_extra_params(component))) - converter = self.__find_value_converter(params, value) - serialized = converter.serialize(value, params, context) - output.append(ContentLine(name=self.ics_name, params=params, value=serialized)) - - def __serialize_multi(self, component: "Component", output: "Container", context: ContextDict): - extra_params = cast(List[ExtraParams], self.get_extra_params(component)) - values = self.get_value_list(component) - if len(extra_params) != len(values): - raise ValueError("length of extra params doesn't match length of parameters" - " for attribute %s of %r" % (self.attribute.name, component)) - - merge_next = False - current_params = None - current_values = [] - - for value, params in zip(values, extra_params): - merge_next = False - params = copy_extra_params(params) - if params.pop("__merge_next", False): # type: ignore - merge_next = True - converter = self.__find_value_converter(params, value) - serialized = converter.serialize(value, params, context) # might modify params and context - - if current_params is not None: - if current_params != params: - raise ValueError() - else: - current_params = params - - current_values.append(serialized) - - if not merge_next: - cl = ContentLine(name=self.ics_name, params=params, value=converter.join_value_list(current_values)) - output.append(cl) - current_params = None - current_values = [] - - if merge_next: - raise ValueError("last value in value list may not have merge_next set") diff --git a/ics/event.py b/ics/event.py deleted file mode 100644 index 2ee9f8c2..00000000 --- a/ics/event.py +++ /dev/null @@ -1,257 +0,0 @@ -from datetime import datetime, timedelta -from typing import Any, List, Optional, Tuple, Union - -import attr -from attr.converters import optional as c_optional -from attr.validators import in_, instance_of, optional as v_optional - -from ics.alarm import BaseAlarm -from ics.attendee import Attendee, Organizer -from ics.component import Component -from ics.converter.base import ics_attr_meta -from ics.converter.component import ComponentMeta -from ics.converter.timespan import TimespanConverter -from ics.geo import Geo, make_geo -from ics.timespan import EventTimespan, Timespan -from ics.types import DatetimeLike, EventOrTimespan, EventOrTimespanOrInstant, TimedeltaLike, URL, get_timespan_if_calendar_entry -from ics.utils import check_is_instance, ensure_datetime, ensure_timedelta, ensure_utc, now_in_utc, uid_gen, validate_not_none - -STATUS_VALUES = (None, 'TENTATIVE', 'CONFIRMED', 'CANCELLED') - - -@attr.s(eq=True, order=False) -class CalendarEntryAttrs(Component): - _timespan: Timespan = attr.ib(validator=instance_of(Timespan), metadata=ics_attr_meta(converter=TimespanConverter)) - summary: Optional[str] = attr.ib(default=None) - uid: str = attr.ib(factory=uid_gen) - - description: Optional[str] = attr.ib(default=None) - location: Optional[str] = attr.ib(default=None) - url: Optional[str] = attr.ib(default=None) - status: Optional[str] = attr.ib(default=None, converter=c_optional(str.upper), validator=v_optional(in_(STATUS_VALUES))) # type: ignore - - created: Optional[datetime] = attr.ib(default=None, converter=ensure_utc) # type: ignore - last_modified: Optional[datetime] = attr.ib(default=None, converter=ensure_utc) # type: ignore - dtstamp: datetime = attr.ib(factory=now_in_utc, converter=ensure_utc, validator=validate_not_none) # type: ignore - - alarms: List[BaseAlarm] = attr.ib(factory=list, converter=list) - attach: List[Union[URL, bytes]] = attr.ib(factory=list, converter=list) - - def __init_subclass__(cls): - super().__init_subclass__() - for cmp in ("__lt__", "__gt__", "__le__", "__ge__"): - child_cmp, parent_cmp = getattr(cls, cmp), getattr(CalendarEntryAttrs, cmp) - if child_cmp != parent_cmp: - raise TypeError("%s may not overwrite %s" % (child_cmp, parent_cmp)) - - #################################################################################################################### - - @property - def begin(self) -> Optional[datetime]: - """Get or set the beginning of the event. - - | Will return a :class:`datetime` object. - | May be set to anything that :func:`datetime.__init__` understands. - | If an end is defined (not a duration), .begin must not - be set to a superior value. - | For all-day events, the time is truncated to midnight when set. - """ - return self._timespan.get_begin() - - @begin.setter - def begin(self, value: DatetimeLike): - self._timespan = self._timespan.replace(begin_time=ensure_datetime(value)) - - @property - def end(self) -> Optional[datetime]: - """Get or set the end of the event. - - | Will return a :class:`datetime` object. - | May be set to anything that :func:`datetime.__init__` understands. - | If set to a non null value, removes any already - existing duration. - | Setting to None will have unexpected behavior if - begin is not None. - | Must not be set to an inferior value than self.begin. - | When setting end time for for all-day events, if the end time - is midnight, that day is not included. Otherwise, the end is - rounded up to midnight the next day, including the full day. - Note that rounding is different from :func:`make_all_day`. - """ - return self._timespan.get_effective_end() - - @end.setter - def end(self, value: DatetimeLike): - self._timespan = self._timespan.replace(end_time=ensure_datetime(value), duration=None) - - @property - def duration(self) -> Optional[timedelta]: - """Get or set the duration of the event. - - | Will return a timedelta object. - | May be set to anything that timedelta() understands. - | May be set with a dict ({"days":2, "hours":6}). - | If set to a non null value, removes any already - existing end time. - | Duration of an all-day event is rounded up to a full day. - """ - return self._timespan.get_effective_duration() - - @duration.setter - def duration(self, value: timedelta): - self._timespan = self._timespan.replace(duration=ensure_timedelta(value), end_time=None) - - def convert_end(self, representation): - self._timespan = self._timespan.convert_end(representation) - - @property - def end_representation(self) -> Optional[str]: - return self._timespan.get_end_representation() - - @property - def has_explicit_end(self) -> bool: - return self._timespan.has_explicit_end() - - @property - def all_day(self) -> bool: - return self._timespan.is_all_day() - - def make_all_day(self): - """Transforms self to an all-day event or a time-based event. - - | The event will span all the days from the begin to *and including* - the end day. For example, assume begin = 2018-01-01 10:37, - end = 2018-01-02 14:44. After make_all_day, begin = 2018-01-01 - [00:00], end = 2018-01-03 [00:00], and duration = 2 days. - | If duration is used instead of the end time, it is rounded up to an - even day. 2 days remains 2 days, but 2 days and one second becomes 3 days. - | If neither duration not end are set, a duration of one day is implied. - | If self is already all-day, it is unchanged. - """ - self._timespan = self._timespan.make_all_day() - - def unset_all_day(self): - self._timespan = self._timespan.replace(precision="seconds") - - @property - def floating(self) -> bool: - return self._timespan.is_floating() - - def replace_timezone(self, tzinfo): - self._timespan = self._timespan.replace_timezone(tzinfo) - - def convert_timezone(self, tzinfo): - self._timespan = self._timespan.convert_timezone(tzinfo) - - @property - def timespan(self) -> Timespan: - return self._timespan - - def __str__(self) -> str: - name = [self.__class__.__name__] - if self.summary: - name.append("'%s'" % self.summary) - prefix, _, suffix = self._timespan.get_str_segments() - return "<%s>" % (" ".join(prefix + name + suffix)) - - #################################################################################################################### - - def cmp_tuple(self) -> Tuple[datetime, datetime, str]: - return (*self.timespan.cmp_tuple(), self.summary or "") - - def __lt__(self, other: Any) -> bool: - """self < other""" - if isinstance(other, CalendarEntryAttrs): - return self.cmp_tuple() < other.cmp_tuple() - else: - return NotImplemented - - def __gt__(self, other: Any) -> bool: - """self > other""" - if isinstance(other, CalendarEntryAttrs): - return self.cmp_tuple() > other.cmp_tuple() - else: - return NotImplemented - - def __le__(self, other: Any) -> bool: - """self <= other""" - if isinstance(other, CalendarEntryAttrs): - return self.cmp_tuple() <= other.cmp_tuple() - else: - return NotImplemented - - def __ge__(self, other: Any) -> bool: - """self >= other""" - if isinstance(other, CalendarEntryAttrs): - return self.cmp_tuple() >= other.cmp_tuple() - else: - return NotImplemented - - def starts_within(self, second: EventOrTimespan) -> bool: - return self._timespan.starts_within(get_timespan_if_calendar_entry(second)) - - def ends_within(self, second: EventOrTimespan) -> bool: - return self._timespan.ends_within(get_timespan_if_calendar_entry(second)) - - def intersects(self, second: EventOrTimespan) -> bool: - return self._timespan.intersects(get_timespan_if_calendar_entry(second)) - - def includes(self, second: EventOrTimespanOrInstant) -> bool: - return self._timespan.includes(get_timespan_if_calendar_entry(second)) - - def is_included_in(self, second: EventOrTimespan) -> bool: - return self._timespan.is_included_in(get_timespan_if_calendar_entry(second)) - - -@attr.s(eq=True, order=False) # order methods are provided by CalendarEntryAttrs -class EventAttrs(CalendarEntryAttrs): - classification: Optional[str] = attr.ib(default=None, validator=v_optional(instance_of(str))) - - transparent: Optional[bool] = attr.ib(default=None) - organizer: Optional[Organizer] = attr.ib(default=None, validator=v_optional(instance_of(Organizer))) - geo: Optional[Geo] = attr.ib(default=None, converter=make_geo) # type: ignore - - attendees: List[Attendee] = attr.ib(factory=list, converter=list) - categories: List[str] = attr.ib(factory=list, converter=list) - - def add_attendee(self, attendee: Attendee): - """ Add an attendee to the attendees set """ - check_is_instance("attendee", attendee, Attendee) - self.attendees.append(attendee) - - -class Event(EventAttrs): - """A calendar event. - - Can be full-day or between two instants. - Can be defined by a beginning instant and - a duration *or* end instant. - - Unsupported event attributes can be found in `event.extra`, - a :class:`ics.parse.Container`. You may add some by appending a - :class:`ics.parse.ContentLine` to `.extra` - """ - - _timespan: EventTimespan = attr.ib(validator=instance_of(EventTimespan)) - - Meta = ComponentMeta("VEVENT") - - def __init__( - self, - summary: str = None, - begin: DatetimeLike = None, - end: DatetimeLike = None, - duration: TimedeltaLike = None, - *args, **kwargs - ): - """Initializes a new :class:`ics.event.Event`. - - Raises: - ValueError: if `timespan` and any of `begin`, `end` or `duration` - are specified at the same time, - or if validation of the timespan fails (see :method:`ics.timespan.Timespan.validate`). - """ - if (begin is not None or end is not None or duration is not None) and "timespan" in kwargs: - raise ValueError("can't specify explicit timespan together with any of begin, end or duration") - kwargs.setdefault("timespan", EventTimespan(ensure_datetime(begin), ensure_datetime(end), ensure_timedelta(duration))) - super(Event, self).__init__(kwargs.pop("timespan"), summary, *args, **kwargs) diff --git a/ics/geo.py b/ics/geo.py deleted file mode 100644 index 64040fce..00000000 --- a/ics/geo.py +++ /dev/null @@ -1,26 +0,0 @@ -from typing import Dict, NamedTuple, Tuple, Union, overload - - -class Geo(NamedTuple): - latitude: float - longitude: float - # TODO also store params like comment? - - -@overload -def make_geo(value: None) -> None: - ... - - -@overload -def make_geo(value: Union[Dict[str, float], Tuple[float, float]]) -> "Geo": - ... - - -def make_geo(value): - if isinstance(value, dict): - return Geo(**value) - elif isinstance(value, tuple): - return Geo(*value) - else: - return None diff --git a/ics/grammar/__init__.py b/ics/grammar/__init__.py deleted file mode 100644 index d081cfe0..00000000 --- a/ics/grammar/__init__.py +++ /dev/null @@ -1,296 +0,0 @@ -import functools -import re -import warnings -from collections import UserString -from typing import Generator, List, MutableSequence, Union - -import attr -import importlib_resources # type: ignore -import tatsu # type: ignore -from tatsu.exceptions import FailedToken # type: ignore - -from ics.types import ContainerItem, ExtraParams, RuntimeAttrValidation, copy_extra_params -from ics.utils import limit_str_length, next_after_str_escape, validate_truthy - -__all__ = ["ParseError", "QuotedParamValue", "ContentLine", "Container", "string_to_container"] - -GRAMMAR = tatsu.compile(importlib_resources.read_text(__name__, "contentline.ebnf")) - - -class ParseError(Exception): - pass - - -class QuotedParamValue(UserString): - pass - - -@attr.s -class ContentLine(RuntimeAttrValidation): - """ - Represents one property line. - - For example: - - ``FOO;BAR=1:YOLO`` is represented by - - ``ContentLine('FOO', {'BAR': ['1']}, 'YOLO'))`` - """ - - name: str = attr.ib(converter=str.upper) # type: ignore - params: ExtraParams = attr.ib(factory=lambda: ExtraParams(dict())) - value: str = attr.ib(default="") - - # TODO store value type for jCal and line number for error messages - - def serialize(self): - return "".join(self.serialize_iter()) - - def serialize_iter(self, newline=False): - yield self.name - for pname in self.params: - yield ";" - yield pname - yield "=" - for nr, pval in enumerate(self.params[pname]): - if nr > 0: - yield "," - if isinstance(pval, QuotedParamValue) or re.search("[:;,]", pval): - # Property parameter values that contain the COLON, SEMICOLON, or COMMA character separators - # MUST be specified as quoted-string text values. - # TODO The DQUOTE character is used as a delimiter for parameter values that contain - # restricted characters or URI text. - # TODO Property parameter values that are not in quoted-strings are case-insensitive. - yield '"%s"' % escape_param(pval) - else: - yield escape_param(pval) - yield ":" - yield self.value - if newline: - yield "\r\n" - - def __getitem__(self, item): - return self.params[item] - - def __setitem__(self, item, values): - self.params[item] = list(values) - - @classmethod - def parse(cls, line): - """Parse a single iCalendar-formatted line into a ContentLine""" - if "\n" in line or "\r" in line: - raise ValueError("ContentLine can only contain escaped newlines") - try: - ast = GRAMMAR.parse(line) - except FailedToken: - raise ParseError() - else: - return cls.interpret_ast(ast) - - @classmethod - def interpret_ast(cls, ast): - name = ast['name'] - value = ast['value'] - params = ExtraParams(dict()) - for param_ast in ast.get('params', []): - param_name = param_ast["name"] - params[param_name] = [] - for param_value_ast in param_ast["values_"]: - val = unescape_param(param_value_ast["value"]) - if param_value_ast["quoted"] == "true": - val = QuotedParamValue(val) - params[param_name].append(val) - return cls(name, params, value) - - def clone(self): - """Makes a copy of itself""" - return attr.evolve(self, params=copy_extra_params(self.params)) - - def __str__(self): - return "%s%s='%s'" % (self.name, self.params or "", limit_str_length(self.value)) - - -def _wrap_list_func(list_func): - @functools.wraps(list_func) - def wrapper(self, *args, **kwargs): - return list_func(self.data, *args, **kwargs) - - return wrapper - - -@attr.s(repr=False) -class Container(MutableSequence[ContainerItem]): - """Represents an iCalendar object. - Contains a list of ContentLines or Containers. - - Args: - - name: the name of the object (VCALENDAR, VEVENT etc.) - items: Containers or ContentLines - """ - - name: str = attr.ib(converter=str.upper, validator=validate_truthy) # type:ignore - data: List[ContainerItem] = attr.ib(converter=list, default=[], - validator=lambda inst, attr, value: inst.check_items(*value)) - - def __str__(self): - return "%s[%s]" % (self.name, ", ".join(str(cl) for cl in self.data)) - - def __repr__(self): - return "%s(%r, %s)" % (type(self).__name__, self.name, repr(self.data)) - - def serialize(self): - return "".join(self.serialize_iter()) - - def serialize_iter(self, newline=False): - yield "BEGIN:" - yield self.name - yield "\r\n" - for line in self: - yield from line.serialize_iter(newline=True) - yield "END:" - yield self.name - if newline: - yield "\r\n" - - @classmethod - def parse(cls, name, tokenized_lines): - items = [] - if not name.isupper(): - warnings.warn("Container 'BEGIN:%s' is not all-uppercase" % name) - for line in tokenized_lines: - if line.name == 'BEGIN': - items.append(cls.parse(line.value, tokenized_lines)) - elif line.name == 'END': - if line.value.upper() != name.upper(): - raise ParseError( - "Expected END:{}, got END:{}".format(name, line.value)) - if not name.isupper(): - warnings.warn("Container 'END:%s' is not all-uppercase" % name) - break - else: - items.append(line) - else: # if break was not called - raise ParseError("Missing END:{}".format(name)) - return cls(name, items) - - def clone(self, items=None, deep=False): - """Makes a copy of itself""" - if items is None: - items = self.data - if deep: - items = (item.clone() for item in items) - return attr.evolve(self, data=items) - - @staticmethod - def check_items(*items): - from ics.utils import check_is_instance - if len(items) == 1: - check_is_instance("item", items[0], (ContentLine, Container)) - else: - for nr, item in enumerate(items): - check_is_instance("item %s" % nr, item, (ContentLine, Container)) - - def __setitem__(self, index, value): # index might be slice and value might be iterable - self.data.__setitem__(index, value) - attr.validate(self) - - def insert(self, index, value): - self.check_items(value) - self.data.insert(index, value) - - def append(self, value): - self.check_items(value) - self.data.append(value) - - def extend(self, values): - self.data.extend(values) - attr.validate(self) - - def __getitem__(self, i): - if isinstance(i, slice): - return attr.evolve(self, data=self.data[i]) - else: - return self.data[i] - - __contains__ = _wrap_list_func(list.__contains__) - __delitem__ = _wrap_list_func(list.__delitem__) - __iter__ = _wrap_list_func(list.__iter__) - __len__ = _wrap_list_func(list.__len__) - __reversed__ = _wrap_list_func(list.__reversed__) - clear = _wrap_list_func(list.clear) - count = _wrap_list_func(list.count) - index = _wrap_list_func(list.index) - pop = _wrap_list_func(list.pop) - remove = _wrap_list_func(list.remove) - reverse = _wrap_list_func(list.reverse) - - -def escape_param(string: Union[str, QuotedParamValue]) -> str: - return str(string).translate( - {ord("\""): "^'", - ord("^"): "^^", - ord("\n"): "^n", - ord("\r"): ""}) - - -def unescape_param(string: str) -> str: - return "".join(unescape_param_iter(string)) - - -def unescape_param_iter(string: str) -> Generator[str, None, None]: - it = iter(string) - for c1 in it: - if c1 == "^": - c2 = next_after_str_escape(it, full_str=string) - if c2 == "n": - yield "\n" - elif c2 == "^": - yield "^" - elif c2 == "'": - yield "\"" - else: - yield c1 - yield c2 - else: - yield c1 - - -def unfold_lines(physical_lines): - current_line = '' - for line in physical_lines: - line = line.rstrip('\r') - if not current_line: - current_line = line - elif line[0] in (' ', '\t'): - current_line += line[1:] - else: - yield current_line - current_line = line - if current_line: - yield current_line - - -def tokenize_line(unfolded_lines): - for line in unfolded_lines: - yield ContentLine.parse(line) - - -def parse(tokenized_lines): - # tokenized_lines must be an iterator, so that Container.parse can consume/steal lines - tokenized_lines = iter(tokenized_lines) - res = [] - for line in tokenized_lines: - if line.name == 'BEGIN': - res.append(Container.parse(line.value, tokenized_lines)) - else: - res.append(line) - return res - - -def lines_to_container(lines): - return parse(tokenize_line(unfold_lines(lines))) - - -def string_to_container(txt): - return lines_to_container(txt.splitlines()) diff --git a/ics/grammar/contentline.ebnf b/ics/grammar/contentline.ebnf deleted file mode 100644 index 5a5224ff..00000000 --- a/ics/grammar/contentline.ebnf +++ /dev/null @@ -1,37 +0,0 @@ -@@grammar::contentline -@@whitespace :: None - -start = contentline $ ; - -ALPHADIGIT = ? "[a-zA-Z0-9]"; -ALPHADIGIT_3_OR_MORE = ? "[a-zA-Z0-9]{3,}"; -ALPHADIGIT_MINUS = ? "[a-zA-Z0-9\-]"; -ALPHADIGIT_MINUS_PLUS = ? "[a-zA-Z0-9\-]+"; -WSP = " "; - -DQUOTE = '"' ; - -QSAFE_CHAR = ?"[^\x00-\x08\x0A-\x1F\x22\x7F]"; -QSAFE_CHAR_STAR = ?"[^\x00-\x08\x0A-\x1F\x22\x7F]*"; - -SAFE_CHAR = ?"[^\x00-\x08\x0A-\x1F\x22\x2C\x3A\x3B\x7F]"; -SAFE_CHAR_STAR = ?"[^\x00-\x08\x0A-\x1F\x22\x2C\x3A\x3B\x7F]*"; - -VALUE_CHAR = ?"[^\x00-\x08\x0A-\x1F\x7F]"; -VALUE_CHAR_STAR = ?"[^\x00-\x08\x0A-\x1F\x7F]*"; - - -name = iana_token | x_name ; -iana_token = ALPHADIGIT_MINUS_PLUS ; -x_name = "X-" [vendorid "-"] ALPHADIGIT_MINUS_PLUS ; -vendorid = ALPHADIGIT_3_OR_MORE ; - -contentline = name:name {(";" params+:param )}* ":" value:value ; - -param = name:param_name "=" values+:param_value {("," values+:param_value)}* ; -param_name = iana_token | x_name ; -param_value = value:quoted_string quoted:`true` | value:paramtext quoted:`false` ; - -paramtext = SAFE_CHAR_STAR ; -value = VALUE_CHAR_STAR ; -quoted_string = DQUOTE @:QSAFE_CHAR_STAR DQUOTE ; diff --git a/ics/icalendar.py b/ics/icalendar.py deleted file mode 100644 index 31ab4df4..00000000 --- a/ics/icalendar.py +++ /dev/null @@ -1,113 +0,0 @@ -from datetime import tzinfo -from typing import ClassVar, Iterable, Iterator, List, Optional, Union - -import attr -from attr.validators import instance_of - -from ics.component import Component -from ics.converter.component import ComponentMeta -from ics.event import Event -from ics.grammar import Container, string_to_container -from ics.timeline import Timeline -from ics.todo import Todo - - -@attr.s -class CalendarAttrs(Component): - version: str = attr.ib(validator=instance_of(str)) # default set by Calendar.Meta.DEFAULT_VERSION - prodid: str = attr.ib(validator=instance_of(str)) # default set by Calendar.Meta.DEFAULT_PRODID - scale: Optional[str] = attr.ib(default=None) - method: Optional[str] = attr.ib(default=None) - - _timezones: List[tzinfo] = attr.ib(factory=list, converter=list) # , init=False, repr=False, eq=False, order=False, hash=False) - events: List[Event] = attr.ib(factory=list, converter=list) - todos: List[Todo] = attr.ib(factory=list, converter=list) - - -class Calendar(CalendarAttrs): - """ - Represents an unique RFC 5545 iCalendar. - - Attributes: - - events: a list of `Event`s contained in the Calendar - todos: a list of `Todo`s contained in the Calendar - timeline: a `Timeline` instance for iterating this Calendar in chronological order - - """ - - Meta = ComponentMeta("VCALENDAR") - DEFAULT_VERSION: ClassVar[str] = "2.0" - DEFAULT_PRODID: ClassVar[str] = "ics.py - http://git.io/lLljaA" - - def __init__( - self, - imports: Union[str, Container, None] = None, - events: Optional[Iterable[Event]] = None, - todos: Optional[Iterable[Todo]] = None, - creator: str = None, - **kwargs - ): - """Initializes a new Calendar. - - Args: - imports (**str**): data to be imported into the Calendar, - events (**Iterable[Event]**): `Event`s to be added to the calendar - todos (**Iterable[Todo]**): `Todo`s to be added to the calendar - creator (**string**): uid of the creator program. - """ - if events is None: - events = tuple() - if todos is None: - todos = tuple() - kwargs.setdefault("version", self.DEFAULT_VERSION) - kwargs.setdefault("prodid", creator if creator is not None else self.DEFAULT_PRODID) - super(Calendar, self).__init__(events=events, todos=todos, **kwargs) # type: ignore - self.timeline = Timeline(self, None) - - if imports is not None: - if isinstance(imports, Container): - self.populate(imports) - else: - containers = string_to_container(imports) - if len(containers) != 1: - raise ValueError("Multiple calendars in one file are not supported by this method." - "Use ics.Calendar.parse_multiple()") - self.populate(containers[0]) - - @property - def creator(self) -> str: - return self.prodid - - @creator.setter - def creator(self, value: str): - self.prodid = value - - @classmethod - def parse_multiple(cls, string): - """" - Parses an input string that may contain mutiple calendars - and retruns a list of :class:`ics.event.Calendar` - """ - containers = string_to_container(string) - return [cls(imports=c) for c in containers] - - def __str__(self) -> str: - return "".format( - len(self.events), - "s" if len(self.events) > 1 else "", - len(self.todos), - "s" if len(self.todos) > 1 else "") - - def __iter__(self) -> Iterator[str]: - """Returns: - iterable: an iterable version of __str__, line per line - (with line-endings). - - Example: - Can be used to write calendar to a file: - - >>> c = Calendar(); c.events.append(Event(summary="My cool event")) - >>> open('my.ics', 'w').writelines(c) - """ - return iter(self.serialize().splitlines(keepends=True)) diff --git a/ics/timeline.py b/ics/timeline.py deleted file mode 100644 index e6ef0c84..00000000 --- a/ics/timeline.py +++ /dev/null @@ -1,145 +0,0 @@ -import heapq -from datetime import date, datetime, timedelta -from typing import Iterable, Iterator, Optional, TYPE_CHECKING, Tuple - -import attr - -from ics.event import Event -from ics.timespan import Normalization, Timespan -from ics.types import DatetimeLike, OptionalDatetimeLike, TimespanOrBegin -from ics.utils import ceil_datetime_to_midnight, ensure_datetime, floor_datetime_to_midnight - -if TYPE_CHECKING: - from ics.icalendar import Calendar - - -@attr.s -class Timeline(object): - """ - `Timeline`s allow iterating all event from a `Calendar` in chronological order, optionally also filtering events - according to their timestamps. - """ - - _calendar: "Calendar" = attr.ib() - _normalization: Optional[Normalization] = attr.ib() - - def __normalize_datetime(self, instant: DatetimeLike) -> datetime: - """ - Create a normalized datetime instance for the given instance. - """ - instant = ensure_datetime(instant) - if self._normalization: - instant = self._normalization.normalize(instant) - return instant - - def __normalize_timespan(self, start: TimespanOrBegin, stop: OptionalDatetimeLike = None) -> Timespan: - """ - Create a normalized timespan between `start` and `stop`. - Alternatively, this method can be called directly with a single timespan as parameter. - """ - if isinstance(start, Timespan): - if stop is not None: - raise ValueError("can't specify a Timespan and an additional stop time") - timespan = start - else: - timespan = Timespan(ensure_datetime(start), ensure_datetime(stop)) - if self._normalization: - timespan = self._normalization.normalize(timespan) - return timespan - - def iterator(self) -> Iterator[Tuple[Timespan, Event]]: - """ - Iterates on every event from the :class:`ics.icalendar.Calendar` in chronological order - - Note: - - chronological order is defined by the comparison operators in :class:`ics.timespan.Timespan` - - Events with no `begin` will not appear here. (To list all events in a `Calendar` use `Calendar.events`) - """ - # Using a heap is faster than sorting if the number of events (n) is - # much bigger than the number of events we extract from the iterator (k). - # Complexity: O(n + k log n). - heap: Iterable[Tuple[Timespan, Event]] = ( - (self.__normalize_timespan(e.timespan), e) - for e in self._calendar.events) - heap = [t for t in heap if t[0]] - heapq.heapify(heap) - while heap: - yield heapq.heappop(heap) - - def __iter__(self) -> Iterator[Event]: - """ - Iterates on every event from the :class:`ics.icalendar.Calendar` in chronological order - - Note: - - chronological order is defined by the comparison operators in :class:`ics.timespan.Timespan` - - Events with no `begin` will not appear here. (To list all events in a `Calendar` use `Calendar.events`) - """ - for _, e in self.iterator(): - yield e - - def included(self, start: TimespanOrBegin, stop: OptionalDatetimeLike = None) -> Iterator[Event]: - """ - Iterates (in chronological order) over every event that is included in the timespan between `start` and `stop`. - Alternatively, this method can be called directly with a single timespan as parameter. - """ - query = self.__normalize_timespan(start, stop) - for timespan, event in self.iterator(): - if timespan.is_included_in(query): - yield event - - def overlapping(self, start: TimespanOrBegin, stop: OptionalDatetimeLike = None) -> Iterator[Event]: - """ - Iterates (in chronological order) over every event that has an intersection with the timespan between `start` and `stop`. - Alternatively, this method can be called directly with a single timespan as parameter. - """ - query = self.__normalize_timespan(start, stop) - for timespan, event in self.iterator(): - if timespan.intersects(query): - yield event - - def start_after(self, instant: DatetimeLike) -> Iterator[Event]: - """ - Iterates (in chronological order) on every event from the :class:`ics.icalendar.Calendar` in chronological order. - The first event of the iteration has a starting date greater (later) than `instant`. - """ - instant = self.__normalize_datetime(instant) - for timespan, event in self.iterator(): - if timespan.begin_time is not None and timespan.begin_time > instant: - yield event - - def at(self, instant: DatetimeLike) -> Iterator[Event]: - """ - Iterates (in chronological order) over all events that are occuring during `instant`. - """ - instant = self.__normalize_datetime(instant) - for timespan, event in self.iterator(): - if timespan.includes(instant): - yield event - - def on(self, instant: DatetimeLike, strict: bool = False) -> Iterator[Event]: - """ - Iterates (in chronological order) over all events that occurs on `day`. - - :param strict: if True events will be returned only if they are strictly *included* in `day` - """ - begin = floor_datetime_to_midnight(ensure_datetime(instant)) - end = ceil_datetime_to_midnight(ensure_datetime(instant)) - timedelta(seconds=1) - query = self.__normalize_timespan(begin, end) - if strict: - return self.included(query) - else: - return self.overlapping(query) - - def today(self, strict: bool = False) -> Iterator[Event]: - """ - Iterates (in chronological order) over all events that occurs today. - - :param strict: if True events will be returned only if they are strictly *included* in `day` - """ - return self.on(date.today(), strict=strict) - - def now(self) -> Iterator[Event]: - """ - Iterates (in chronological order) over all events that occur right now. - """ - return self.at(datetime.utcnow()) diff --git a/ics/timespan.py b/ics/timespan.py deleted file mode 100644 index b57a0f09..00000000 --- a/ics/timespan.py +++ /dev/null @@ -1,439 +0,0 @@ -from datetime import datetime, timedelta, tzinfo as TZInfo -from typing import Any, Callable, NamedTuple, Optional, TypeVar, Union, cast, overload - -import attr -from attr.validators import instance_of, optional as v_optional -from dateutil.tz import tzlocal - -from ics.types import DatetimeLike -from ics.utils import TIMEDELTA_CACHE, TIMEDELTA_DAY, TIMEDELTA_ZERO, ceil_datetime_to_midnight, ensure_datetime, floor_datetime_to_midnight, timedelta_nearly_zero - - -@attr.s -class Normalization(object): - normalize_floating: bool = attr.ib() - normalize_with_tz: bool = attr.ib() - replacement: Union[TZInfo, Callable[[], TZInfo], None] = attr.ib() - - @overload - def normalize(self, value: "Timespan") -> "Timespan": - ... - - @overload # noqa - def normalize(self, value: DatetimeLike) -> datetime: - ... - - @overload # noqa - def normalize(self, value: None) -> None: - ... - - def normalize(self, value): # noqa - """ - Normalize datetime or timespan instances to make naive/floating ones (without timezone, i.e. tzinfo == None) - comparable to aware ones with a fixed timezone. - If None is selected as replacement, the timezone information will be stripped from aware datetimes. - If the replacement is set to any tzinfo instance, naive datetimes will be interpreted in that timezone. - """ - if value is None: - return None - elif not isinstance(value, Timespan): - value = ensure_datetime(value) - floating = (value.tzinfo is None) - replace_timezone = lambda value, tzinfo: value.replace(tzinfo=tzinfo) - else: - floating = value.is_floating() - replace_timezone = Timespan.replace_timezone - - normalize = (floating and self.normalize_floating) or (not floating and self.normalize_with_tz) - - if normalize: - replacement = self.replacement - if callable(replacement): - replacement = replacement() - return replace_timezone(value, replacement) - else: - return value - - -# using datetime.min might lead to problems when doing timezone conversions / comparisions (e.g. by substracting an 1 hour offset) -CMP_DATETIME_NONE_DEFAULT = datetime(1900, 1, 1, 0, 0) -CMP_NORMALIZATION = Normalization(normalize_floating=True, normalize_with_tz=False, replacement=tzlocal) - -TimespanTuple = NamedTuple("TimespanTuple", [("begin", datetime), ("end", datetime)]) -NullableTimespanTuple = NamedTuple("NullableTimespanTuple", [("begin", Optional[datetime]), ("end", Optional[datetime])]) - -TimespanT = TypeVar('TimespanT', bound='Timespan') - - -@attr.s(slots=True, frozen=True, eq=True, order=False) -class Timespan(object): - begin_time: Optional[datetime] = attr.ib(validator=v_optional(instance_of(datetime)), default=None) - end_time: Optional[datetime] = attr.ib(validator=v_optional(instance_of(datetime)), default=None) - duration: Optional[timedelta] = attr.ib(validator=v_optional(instance_of(timedelta)), default=None) - precision: str = attr.ib(default="second") - - def _end_name(self) -> str: - return "end" - - def __attrs_post_init__(self): - self.validate() - - def replace( - self: TimespanT, - begin_time: Optional[datetime] = False, # type: ignore - end_time: Optional[datetime] = False, # type: ignore - duration: Optional[timedelta] = False, # type: ignore - precision: str = False # type: ignore - ) -> TimespanT: - if begin_time is False: - begin_time = self.begin_time - if end_time is False: - end_time = self.end_time - if duration is False: - duration = self.duration - if precision is False: - precision = self.precision - return type(self)(begin_time=begin_time, end_time=end_time, duration=duration, precision=precision) - - def replace_timezone(self: TimespanT, tzinfo: Optional[TZInfo]) -> TimespanT: - if self.is_all_day(): - raise ValueError("can't replace timezone of all-day event") - begin = self.get_begin() - if begin is not None: - begin = begin.replace(tzinfo=tzinfo) - if self.end_time is not None: - return self.replace(begin_time=begin, end_time=self.end_time.replace(tzinfo=tzinfo)) - else: - return self.replace(begin_time=begin) - - def convert_timezone(self: TimespanT, tzinfo: Optional[TZInfo]) -> TimespanT: - if self.is_all_day(): - raise ValueError("can't convert timezone of all-day timespan") - if self.is_floating(): - raise ValueError("can't convert timezone of timezone-naive floating timespan, use replace_timezone") - begin = self.get_begin() - if begin is not None: - begin = begin.astimezone(tzinfo) - if self.end_time is not None: - return self.replace(begin_time=begin, end_time=self.end_time.astimezone(tzinfo)) - else: - return self.replace(begin_time=begin) - - def validate(self): - def validate_timeprecision(value, name): - if self.precision == "day": - if floor_datetime_to_midnight(value) != value: - raise ValueError("%s time value %s has higher precision than set precision %s" % (name, value, self.precision)) - if value.tzinfo is not None: - raise ValueError("all-day timespan %s time %s can't have a timezone" % (name, value)) - - if self.begin_time is not None: - validate_timeprecision(self.begin_time, "begin") - - if self.end_time is not None: - validate_timeprecision(self.end_time, self._end_name()) - if self.begin_time > self.end_time: - raise ValueError("begin time must be before " + self._end_name() + " time") - if self.precision == "day" and self.end_time < (self.begin_time + TIMEDELTA_DAY): - raise ValueError("all-day timespan duration must be at least one day") - if self.duration is not None: - raise ValueError("can't set duration together with " + self._end_name() + " time") - if self.begin_time.tzinfo is None and self.end_time.tzinfo is not None: - raise ValueError(self._end_name() + " time may not have a timezone as the begin time doesn't either") - if self.begin_time.tzinfo is not None and self.end_time.tzinfo is None: - raise ValueError(self._end_name() + " time must have a timezone as the begin time also does") - duration = self.get_effective_duration() - if duration and not timedelta_nearly_zero(duration % TIMEDELTA_CACHE[self.precision]): - raise ValueError("effective duration value %s has higher precision than set precision %s" % - (self.get_effective_duration(), self.precision)) - - if self.duration is not None: - if self.duration < TIMEDELTA_ZERO: - raise ValueError("timespan duration must be positive") - if self.precision == "day" and self.duration < TIMEDELTA_DAY: - raise ValueError("all-day timespan duration must be at least one day") - if not timedelta_nearly_zero(self.duration % TIMEDELTA_CACHE[self.precision]): - raise ValueError("duration value %s has higher precision than set precision %s" % - (self.duration, self.precision)) - - else: - if self.end_time is not None: - # Todos might have end/due time without begin - validate_timeprecision(self.end_time, self._end_name()) - - if self.duration is not None: - raise ValueError("timespan without begin time can't have duration") - - def get_str_segments(self): - if self.is_all_day(): - prefix = ["all-day"] - elif self.is_floating(): - prefix = ["floating"] - else: - prefix = [] - - suffix = [] - - begin = self.begin_time - if begin is not None: - suffix.append("begin:") - if self.is_all_day(): - suffix.append(begin.strftime('%Y-%m-%d')) - else: - suffix.append(str(begin)) - - end = self.get_effective_end() - end_repr = self.get_end_representation() - if end is not None: - if end_repr == "end": - suffix.append("fixed") - suffix.append(self._end_name() + ":") - if self.is_all_day(): - suffix.append(end.strftime('%Y-%m-%d')) - else: - suffix.append(str(end)) - - duration = self.get_effective_duration() - if duration is not None and end_repr is not None: - if end_repr == "duration": - suffix.append("fixed") - suffix.append("duration:") - suffix.append(str(duration)) - - return prefix, [self.__class__.__name__], suffix - - def __str__(self) -> str: - prefix, name, suffix = self.get_str_segments() - return "<%s>" % (" ".join(prefix + name + suffix)) - - def __bool__(self): - return self.begin_time is not None or self.end_time is not None - - #################################################################################################################### - - def make_all_day(self) -> "Timespan": - if self.is_all_day(): - return self # Do nothing if we already are a all day timespan - - begin = self.begin_time - if begin is not None: - begin = floor_datetime_to_midnight(begin).replace(tzinfo=None) - - end = self.get_effective_end() - if end is not None: - end = ceil_datetime_to_midnight(end).replace(tzinfo=None) - if end == begin: # we also add another day if the duration would be 0 otherwise - end = end + TIMEDELTA_DAY - - if self.get_end_representation() == "duration": - assert end is not None - assert begin is not None - return self.replace(begin, None, end - begin, "day") - else: - return self.replace(begin, end, None, "day") - - def convert_end(self, target: Optional[str]) -> "Timespan": - current = self.get_end_representation() - current_is_end = current == "end" or current == self._end_name() - target_is_end = target == "end" or target == self._end_name() - if current == target or (current_is_end and target_is_end): - return self - elif current_is_end and target == "duration": - return self.replace(end_time=None, duration=self.get_effective_duration()) - elif current == "duration" and target_is_end: - return self.replace(end_time=self.get_effective_end(), duration=None) - elif target is None: - return self.replace(end_time=None, duration=None) - else: - raise ValueError("can't convert from representation %s to %s" % (current, target)) - - #################################################################################################################### - - def get_begin(self) -> Optional[datetime]: - return self.begin_time - - def get_effective_end(self) -> Optional[datetime]: - if self.end_time is not None: - return self.end_time - elif self.begin_time is not None: - duration = self.get_effective_duration() - if duration is not None: - return self.begin_time + duration - - return None - - def get_effective_duration(self) -> Optional[timedelta]: - if self.duration is not None: - return self.duration - elif self.end_time is not None and self.begin_time is not None: - return self.end_time - self.begin_time - else: - return None - - def get_precision(self) -> str: - return self.precision - - def is_all_day(self) -> bool: - return self.precision == "day" - - def is_floating(self) -> bool: - if self.begin_time is None: - if self.end_time is None: - return True - else: - return self.end_time.tzinfo is None - else: - return self.begin_time.tzinfo is None - - def get_end_representation(self) -> Optional[str]: - if self.duration is not None: - return "duration" - elif self.end_time is not None: - return "end" - else: - return None - - def has_explicit_end(self) -> bool: - return self.get_end_representation() is not None - - #################################################################################################################### - - @overload - def timespan_tuple(self, default: None = None, normalization: Normalization = None) -> NullableTimespanTuple: - ... - - @overload # noqa - def timespan_tuple(self, default: datetime, normalization: Normalization = None) -> TimespanTuple: - ... - - def timespan_tuple(self, default=None, normalization=None): # noqa - if normalization: - return TimespanTuple( - normalization.normalize(self.get_begin() or default), - normalization.normalize(self.get_effective_end() or default) - ) - else: - return TimespanTuple( - self.get_begin() or default, - self.get_effective_end() or default - ) - - def cmp_tuple(self) -> TimespanTuple: - return self.timespan_tuple(default=CMP_DATETIME_NONE_DEFAULT, normalization=CMP_NORMALIZATION) - - def __require_tuple_components(self, values, *required): - for nr, (val, req) in enumerate(zip(values, required)): - if req and val is None: - event = "this event" if nr < 2 else "other event" - prop = "begin" if nr % 2 == 0 else "end" - raise ValueError("%s has no %s time" % (event, prop)) - - def starts_within(self, other: "Timespan") -> bool: - first = cast(TimespanTuple, self.timespan_tuple(normalization=CMP_NORMALIZATION)) - second = cast(TimespanTuple, other.timespan_tuple(normalization=CMP_NORMALIZATION)) - self.__require_tuple_components(first + second, True, False, True, True) - - # the timespan doesn't include its end instant / day - return second.begin <= first.begin < second.end - - def ends_within(self, other: "Timespan") -> bool: - first = cast(TimespanTuple, self.timespan_tuple(normalization=CMP_NORMALIZATION)) - second = cast(TimespanTuple, other.timespan_tuple(normalization=CMP_NORMALIZATION)) - self.__require_tuple_components(first + second, False, True, True, True) - - # the timespan doesn't include its end instant / day - return second.begin <= first.end < second.end - - def intersects(self, other: "Timespan") -> bool: - first = cast(TimespanTuple, self.timespan_tuple(normalization=CMP_NORMALIZATION)) - second = cast(TimespanTuple, other.timespan_tuple(normalization=CMP_NORMALIZATION)) - self.__require_tuple_components(first + second, True, True, True, True) - - # the timespan doesn't include its end instant / day - return second.begin <= first.begin < second.end or \ - second.begin <= first.end < second.end or \ - first.begin <= second.begin < first.end or \ - first.begin <= second.end < first.end - - def includes(self, other: Union["Timespan", datetime]) -> bool: - if isinstance(other, datetime): - first = cast(TimespanTuple, self.timespan_tuple(normalization=CMP_NORMALIZATION)) - other = CMP_NORMALIZATION.normalize(other) - self.__require_tuple_components(first, True, True) - - # the timespan doesn't include its end instant / day - return first.begin <= other < first.end - - else: - first = cast(TimespanTuple, self.timespan_tuple(normalization=CMP_NORMALIZATION)) - second = cast(TimespanTuple, other.timespan_tuple(normalization=CMP_NORMALIZATION)) - self.__require_tuple_components(first + second, True, True, True, True) - - # the timespan doesn't include its end instant / day - return first.begin <= second.begin and second.end < first.end - - __contains__ = includes - - def is_included_in(self, other: "Timespan") -> bool: - first = cast(TimespanTuple, self.timespan_tuple(normalization=CMP_NORMALIZATION)) - second = cast(TimespanTuple, other.timespan_tuple(normalization=CMP_NORMALIZATION)) - self.__require_tuple_components(first + second, True, True, True, True) - - # the timespan doesn't include its end instant / day - return second.begin <= first.begin and first.end < second.end - - def __lt__(self, other: Any) -> bool: - if isinstance(other, Timespan): - return self.cmp_tuple() < other.cmp_tuple() - else: - return NotImplemented - - def __gt__(self, other: Any) -> bool: - if isinstance(other, Timespan): - return self.cmp_tuple() > other.cmp_tuple() - else: - return NotImplemented - - def __le__(self, other: Any) -> bool: - if isinstance(other, Timespan): - return self.cmp_tuple() <= other.cmp_tuple() - else: - return NotImplemented - - def __ge__(self, other: Any) -> bool: - if isinstance(other, Timespan): - return self.cmp_tuple() >= other.cmp_tuple() - else: - return NotImplemented - - -class EventTimespan(Timespan): - def _end_name(self): - return "end" - - def validate(self): - super(EventTimespan, self).validate() - if self.begin_time is None and self.end_time is not None: - raise ValueError("event timespan without begin time can't have end time") - - def get_effective_duration(self) -> timedelta: - if self.duration is not None: - return self.duration - elif self.end_time is not None and self.begin_time is not None: - return self.end_time - self.begin_time - elif self.is_all_day(): - return TIMEDELTA_DAY - else: - return TIMEDELTA_ZERO - - -class TodoTimespan(Timespan): - def _end_name(self): - return "due" - - def timespan_tuple(self, default=None, normalization=None): - # Todos compare by (due, begin) instead of (begin, end) - return tuple(reversed( - super(TodoTimespan, self).timespan_tuple( - default=default, normalization=normalization) - )) diff --git a/ics/todo.py b/ics/todo.py deleted file mode 100644 index 801211f9..00000000 --- a/ics/todo.py +++ /dev/null @@ -1,82 +0,0 @@ -# mypy: ignore_errors -# this is because mypy doesn't like the converter of CalendarEntryAttrs.{created,last_modified,dtstamp} and due to some -# bug confuses the files - -import functools -import warnings -from datetime import datetime -from typing import Optional - -import attr -from attr.validators import in_, instance_of, optional as v_optional - -from ics.converter.component import ComponentMeta -from ics.event import CalendarEntryAttrs -from ics.timespan import TodoTimespan -from ics.types import DatetimeLike, TimedeltaLike -from ics.utils import ensure_datetime, ensure_timedelta - -MAX_PERCENT = 100 -MAX_PRIORITY = 9 - - -def deprecated_due(fun): - @functools.wraps(fun) - def wrapper(*args, **kwargs): - msg = "Call to deprecated function {}. Use `due` instead of `end` for class Todo." - warnings.warn( - msg.format(fun.__name__), - category=DeprecationWarning - ) - return fun(*args, **kwargs) - - return wrapper - - -@attr.s(eq=True, order=False) # order methods are provided by CalendarEntryAttrs -class TodoAttrs(CalendarEntryAttrs): - percent: Optional[int] = attr.ib(default=None, validator=v_optional(in_(range(0, MAX_PERCENT + 1)))) - priority: Optional[int] = attr.ib(default=None, validator=v_optional(in_(range(0, MAX_PRIORITY + 1)))) - completed: Optional[datetime] = attr.ib(default=None, converter=ensure_datetime) # type: ignore - - -class Todo(TodoAttrs): - """A todo list entry. - - Can have a start time and duration, or start and due time, - or only start or due time. - """ - _timespan: TodoTimespan = attr.ib(validator=instance_of(TodoTimespan)) - - Meta = ComponentMeta("VTODO") - - def __init__( - self, - begin: DatetimeLike = None, - due: DatetimeLike = None, - duration: TimedeltaLike = None, - *args, **kwargs - ): - if (begin is not None or due is not None or duration is not None) and "timespan" in kwargs: - raise ValueError("can't specify explicit timespan together with any of begin, due or duration") - kwargs.setdefault("timespan", TodoTimespan(ensure_datetime(begin), ensure_datetime(due), ensure_timedelta(duration))) - super(Todo, self).__init__(kwargs.pop("timespan"), *args, **kwargs) - - #################################################################################################################### - - def convert_due(self, representation): - if representation == "due": - representation = "end" - super(Todo, self).convert_end(representation) - - due = property(TodoAttrs.end.fget, TodoAttrs.end.fset) # type: ignore - # convert_due = TodoAttrs.convert_end # see above - due_representation = property(TodoAttrs.end_representation.fget) # type: ignore - has_explicit_due = property(TodoAttrs.has_explicit_end.fget) # type: ignore - due_within = TodoAttrs.ends_within - - end = property(deprecated_due(TodoAttrs.end.fget), deprecated_due(TodoAttrs.end.fset)) # type: ignore - convert_end = deprecated_due(TodoAttrs.convert_end) - end_representation = property(deprecated_due(TodoAttrs.end_representation.fget)) # type: ignore - has_explicit_end = property(deprecated_due(TodoAttrs.has_explicit_end.fget)) # type: ignore - ends_within = deprecated_due(TodoAttrs.ends_within) diff --git a/ics/types.py b/ics/types.py deleted file mode 100644 index 2ae68d15..00000000 --- a/ics/types.py +++ /dev/null @@ -1,176 +0,0 @@ -import functools -import warnings -from datetime import date, datetime, timedelta -from typing import Any, Dict, Iterator, List, MutableMapping, NewType, Optional, TYPE_CHECKING, Tuple, Union, cast, overload -from urllib.parse import ParseResult - -import attr - -if TYPE_CHECKING: - # noinspection PyUnresolvedReferences - from ics.event import Event, CalendarEntryAttrs - # noinspection PyUnresolvedReferences - from ics.todo import Todo - # noinspection PyUnresolvedReferences - from ics.timespan import Timespan - # noinspection PyUnresolvedReferences - from ics.grammar import ContentLine, Container - -__all__ = [ - "ContainerItem", "ContainerList", "URL", - - "DatetimeLike", "OptionalDatetimeLike", - "TimedeltaLike", "OptionalTimedeltaLike", - - "TimespanOrBegin", - "EventOrTimespan", - "EventOrTimespanOrInstant", - "TodoOrTimespan", - "TodoOrTimespanOrInstant", - "CalendarEntryOrTimespan", - "CalendarEntryOrTimespanOrInstant", - - "get_timespan_if_calendar_entry", - - "RuntimeAttrValidation", - - "EmptyDict", "ExtraParams", "EmptyParams", "ContextDict", "EmptyContext", "copy_extra_params", -] - -ContainerItem = Union["ContentLine", "Container"] -ContainerList = List[ContainerItem] -URL = ParseResult - -DatetimeLike = Union[Tuple, Dict, datetime, date] -OptionalDatetimeLike = Union[Tuple, Dict, datetime, date, None] -TimedeltaLike = Union[Tuple, Dict, timedelta] -OptionalTimedeltaLike = Union[Tuple, Dict, timedelta, None] - -TimespanOrBegin = Union[datetime, date, "Timespan"] -EventOrTimespan = Union["Event", "Timespan"] -EventOrTimespanOrInstant = Union["Event", "Timespan", datetime] -TodoOrTimespan = Union["Todo", "Timespan"] -TodoOrTimespanOrInstant = Union["Todo", "Timespan", datetime] -CalendarEntryOrTimespan = Union["CalendarEntryAttrs", "Timespan"] -CalendarEntryOrTimespanOrInstant = Union["CalendarEntryAttrs", "Timespan", datetime] - - -@overload -def get_timespan_if_calendar_entry(value: CalendarEntryOrTimespan) -> "Timespan": - ... - - -@overload -def get_timespan_if_calendar_entry(value: datetime) -> datetime: - ... - - -@overload -def get_timespan_if_calendar_entry(value: None) -> None: - ... - - -def get_timespan_if_calendar_entry(value): - from ics.event import CalendarEntryAttrs # noqa - - if isinstance(value, CalendarEntryAttrs): - return value._timespan - else: - return value - - -@attr.s -class RuntimeAttrValidation(object): - """ - Mixin that automatically calls the converters and validators of `attr` attributes. - The library itself only calls these in the generated `__init__` method, with - this mixin they are also called when later (re-)assigning an attribute, which - is handled by `__setattr__`. This makes setting attributes as versatile as specifying - them as init parameters and also ensures that the guarantees of validators are - preserved even after creation of the object, at a small runtime cost. - """ - - def __attrs_post_init__(self): - self.__post_init__ = True - - def __setattr__(self, key, value): - if getattr(self, "__post_init__", None): - cls = self.__class__ # type: Any - if not getattr(cls, "__attr_fields__", None): - cls.__attr_fields__ = attr.fields_dict(cls) - try: - field = cls.__attr_fields__[key] - except KeyError: - pass - else: # when no KeyError was thrown - if field.converter is not None: - value = field.converter(value) - if field.validator is not None: - field.validator(self, field, value) - super(RuntimeAttrValidation, self).__setattr__(key, value) - - -class EmptyDictType(MutableMapping[Any, None]): - """An empty, immutable dict that returns `None` for any key. Useful as default value for function arguments.""" - - def __getitem__(self, k: Any) -> None: - return None - - def __setitem__(self, k: Any, v: None) -> None: - warnings.warn("%s[%r] = %s ignored" % (self.__class__.__name__, k, v)) - return - - def __delitem__(self, v: Any) -> None: - warnings.warn("del %s[%r] ignored" % (self.__class__.__name__, v)) - return - - def __len__(self) -> int: - return 0 - - def __iter__(self) -> Iterator[Any]: - return iter([]) - - -EmptyDict = EmptyDictType() -ExtraParams = NewType("ExtraParams", Dict[str, List[str]]) -EmptyParams = cast("ExtraParams", EmptyDict) -ContextDict = NewType("ContextDict", Dict[Any, Any]) -EmptyContext = cast("ContextDict", EmptyDict) - - -def copy_extra_params(old: Optional[ExtraParams]) -> ExtraParams: - new: ExtraParams = ExtraParams(dict()) - if not old: - return new - for key, value in old.items(): - if isinstance(value, str): - new[key] = value - elif isinstance(value, list): - new[key] = list(value) - else: - raise ValueError("can't convert extra param %s with value of type %s: %s" % (key, type(value), value)) - return new - - -def attrs_custom_init(cls): - assert attr.has(cls) - attr_init = cls.__init__ - custom_init = cls.__attr_custom_init__ - - @functools.wraps(attr_init) - def new_init(self, *args, **kwargs): - custom_init(self, attr_init, *args, **kwargs) - - cls.__init__ = new_init - cls.__attr_custom_init__ = None - del cls.__attr_custom_init__ - return cls - -# @attrs_custom_init -# @attr.s -# class Test(object): -# val1 = attr.ib() -# val2 = attr.ib() -# -# def __attr_custom_init__(self, attr_init, val1, val1_suffix, *args, **kwargs): -# attr_init(self, val1 + val1_suffix, *args, **kwargs) diff --git a/ics/utils.py b/ics/utils.py deleted file mode 100644 index 38153e20..00000000 --- a/ics/utils.py +++ /dev/null @@ -1,228 +0,0 @@ -from datetime import date, datetime, time, timedelta, timezone -from typing import overload -from uuid import uuid4 - -from dateutil.tz import UTC as dateutil_tzutc - -from ics.types import DatetimeLike, TimedeltaLike - -datetime_tzutc = timezone.utc - -MIDNIGHT = time() -TIMEDELTA_ZERO = timedelta() -TIMEDELTA_DAY = timedelta(days=1) -TIMEDELTA_SECOND = timedelta(seconds=1) -TIMEDELTA_CACHE = { - 0: TIMEDELTA_ZERO, - "day": TIMEDELTA_DAY, - "second": TIMEDELTA_SECOND -} -MAX_TIMEDELTA_NEARLY_ZERO = timedelta(seconds=1) / 2 - - -@overload -def ensure_datetime(value: None) -> None: ... - - -@overload -def ensure_datetime(value: DatetimeLike) -> datetime: ... - - -def ensure_datetime(value): - if value is None: - return None - elif isinstance(value, datetime): - return value - elif isinstance(value, date): - return datetime.combine(value, MIDNIGHT, tzinfo=None) - elif isinstance(value, tuple): - return datetime(*value) - elif isinstance(value, dict): - return datetime(**value) - else: - raise ValueError("can't construct datetime from %s" % repr(value)) - - -@overload -def ensure_utc(value: None) -> None: ... - - -@overload -def ensure_utc(value: DatetimeLike) -> datetime: ... - - -def ensure_utc(value): - value = ensure_datetime(value) - if value is not None: - value = value.astimezone(dateutil_tzutc) - return value - - -def now_in_utc() -> datetime: - return datetime.now(tz=dateutil_tzutc) - - -def is_utc(instant: datetime) -> bool: - tz = instant.tzinfo - if tz is None: - return False - if tz in [dateutil_tzutc, datetime_tzutc]: - return True - tzname = tz.tzname(instant) - if tzname and tzname.upper() == "UTC": - return True - return False - - -@overload -def ensure_timedelta(value: None) -> None: ... - - -@overload -def ensure_timedelta(value: TimedeltaLike) -> timedelta: ... - - -def ensure_timedelta(value): - if value is None: - return None - elif isinstance(value, timedelta): - return value - elif isinstance(value, tuple): - return timedelta(*value) - elif isinstance(value, dict): - return timedelta(**value) - else: - raise ValueError("can't construct timedelta from %s" % repr(value)) - - -############################################################################### -# Rounding Utils - -def timedelta_nearly_zero(td: timedelta) -> bool: - return -MAX_TIMEDELTA_NEARLY_ZERO <= td <= MAX_TIMEDELTA_NEARLY_ZERO - - -@overload -def floor_datetime_to_midnight(value: datetime) -> datetime: ... - - -@overload -def floor_datetime_to_midnight(value: date) -> date: ... - - -@overload -def floor_datetime_to_midnight(value: None) -> None: ... - - -def floor_datetime_to_midnight(value): - if value is None: - return None - if isinstance(value, date) and not isinstance(value, datetime): - return value - return datetime.combine(ensure_datetime(value).date(), MIDNIGHT, tzinfo=value.tzinfo) - - -@overload -def ceil_datetime_to_midnight(value: datetime) -> datetime: ... - - -@overload -def ceil_datetime_to_midnight(value: date) -> date: ... - - -@overload -def ceil_datetime_to_midnight(value: None) -> None: ... - - -def ceil_datetime_to_midnight(value): - if value is None: - return None - if isinstance(value, date) and not isinstance(value, datetime): - return value - floored = floor_datetime_to_midnight(value) - if floored != value: - return floored + TIMEDELTA_DAY - else: - return floored - - -def floor_timedelta_to_days(value: timedelta) -> timedelta: - return value - (value % TIMEDELTA_DAY) - - -def ceil_timedelta_to_days(value: timedelta) -> timedelta: - mod = value % TIMEDELTA_DAY - if mod == TIMEDELTA_ZERO: - return value - else: - return value + TIMEDELTA_DAY - mod - - -############################################################################### -# String Utils - - -def limit_str_length(val): - return str(val) # TODO limit_str_length - - -def next_after_str_escape(it, full_str): - try: - return next(it) - except StopIteration as e: - raise ValueError("value '%s' may not end with an escape sequence" % full_str) from e - - -def uid_gen() -> str: - uid = str(uuid4()) - return "{}@{}.org".format(uid, uid[:4]) - - -############################################################################### - -def validate_not_none(inst, attr, value): - if value is None: - raise ValueError( - "'{name}' may not be None".format( - name=attr.name - ) - ) - - -def validate_truthy(inst, attr, value): - if not bool(value): - raise ValueError( - "'{name}' must be truthy (got {value!r})".format( - name=attr.name, value=value - ) - ) - - -def check_is_instance(name, value, clazz): - if not isinstance(value, clazz): - raise TypeError( - "'{name}' must be {type!r} (got {value!r} that is a " - "{actual!r}).".format( - name=name, - type=clazz, - actual=value.__class__, - value=value, - ), - name, - clazz, - value, - ) - - -def validate_utc(inst, attr, value): - check_is_instance(attr.name, value, datetime) - if not is_utc(value): - raise ValueError( - "'{name}' must be in timezone UTC (got {value!r} which has tzinfo {tzinfo!r})".format( - name=attr.name, value=value, tzinfo=value.tzinfo - ) - ) - - -def call_validate_on_inst(inst, attr, value): - inst.validate(attr, value) diff --git a/ics/valuetype/__init__.py b/ics/valuetype/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ics/valuetype/base.py b/ics/valuetype/base.py deleted file mode 100644 index 883b84c6..00000000 --- a/ics/valuetype/base.py +++ /dev/null @@ -1,51 +0,0 @@ -import abc -import inspect -from typing import Dict, Generic, Iterable, Type, TypeVar - -from ics.types import ContextDict, EmptyContext, EmptyParams, ExtraParams - -T = TypeVar('T') - - -class ValueConverter(Generic[T], abc.ABC): - BY_NAME: Dict[str, "ValueConverter"] = {} - BY_TYPE: Dict[Type, "ValueConverter"] = {} - INST: "ValueConverter" - - def __init_subclass__(cls) -> None: - super(ValueConverter, cls).__init_subclass__() - # isabstract(ValueConverter) == False on python 3.6 - if not inspect.isabstract(cls) and cls.parse is not ValueConverter.parse: - cls.INST = cls() - ValueConverter.BY_NAME[cls.INST.ics_type] = cls.INST - ValueConverter.BY_TYPE.setdefault(cls.INST.python_type, cls.INST) - - @property - @abc.abstractmethod - def ics_type(self) -> str: - pass - - @property - @abc.abstractmethod - def python_type(self) -> Type[T]: - pass - - def split_value_list(self, values: str) -> Iterable[str]: - yield from values.split(",") - - def join_value_list(self, values: Iterable[str]) -> str: - return ",".join(values) - - @abc.abstractmethod - def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> T: - pass - - @abc.abstractmethod - def serialize(self, value: T, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: - pass - - def __str__(self): - return "<" + self.__class__.__name__ + ">" - - def __hash__(self): - return hash(type(self)) diff --git a/ics/valuetype/datetime.py b/ics/valuetype/datetime.py deleted file mode 100644 index c49dedd1..00000000 --- a/ics/valuetype/datetime.py +++ /dev/null @@ -1,294 +0,0 @@ -import re -import warnings -from datetime import date, datetime, time, timedelta -from typing import List, Optional, Type, cast - -from dateutil.tz import UTC as dateutil_tzutc, gettz, tzoffset as UTCOffset - -from ics.timespan import Timespan -from ics.types import ContextDict, EmptyContext, EmptyParams, ExtraParams, copy_extra_params -from ics.utils import is_utc -from ics.valuetype.base import ValueConverter - - -class DatetimeConverterMixin(object): - FORMATS = { - 6: "%Y%m", - 8: "%Y%m%d" - } - CONTEXT_KEY_AVAILABLE_TZ = "DatetimeAvailableTimezones" - - def _serialize_dt(self, value: datetime, params: ExtraParams, context: ContextDict, - utc_fmt="%Y%m%dT%H%M%SZ", nonutc_fmt="%Y%m%dT%H%M%S") -> str: - if is_utc(value): - return value.strftime(utc_fmt) - else: - if value.tzinfo is not None: - tzname = value.tzinfo.tzname(value) - if not tzname: - # TODO generate unique identifier as name - raise ValueError("could not generate name for tzinfo %s" % value.tzinfo) - params["TZID"] = [tzname] - available_tz = context.setdefault(self.CONTEXT_KEY_AVAILABLE_TZ, {}) - available_tz.setdefault(tzname, value.tzinfo) - return value.strftime(nonutc_fmt) - - def _parse_dt(self, value: str, params: ExtraParams, context: ContextDict, - warn_no_avail_tz=True) -> datetime: - param_tz_list: Optional[List[str]] = params.pop("TZID", None) # we remove the TZID from context - if param_tz_list: - if len(param_tz_list) > 1: - raise ValueError("got multiple TZIDs") - param_tz: Optional[str] = param_tz_list[0] - else: - param_tz = None - available_tz = context.get(self.CONTEXT_KEY_AVAILABLE_TZ, None) - if available_tz is None and warn_no_avail_tz: - warnings.warn("DatetimeConverterMixin.parse called without available_tz dict in context") - fixed_utc = (value[-1].upper() == 'Z') - - value = value.translate({ - ord("/"): "", - ord("-"): "", - ord("Z"): "", - ord("z"): ""}) - dt = datetime.strptime(value, self.FORMATS[len(value)]) - - if fixed_utc: - if param_tz: - raise ValueError("can't specify UTC via appended 'Z' and TZID param '%s'" % param_tz) - return dt.replace(tzinfo=dateutil_tzutc) - elif param_tz: - selected_tz = None - if available_tz: - selected_tz = available_tz.get(param_tz, None) - if selected_tz is None: - selected_tz = gettz(param_tz) # be lenient with missing vtimezone definitions - return dt.replace(tzinfo=selected_tz) - else: - return dt - - -class DatetimeConverter(DatetimeConverterMixin, ValueConverter[datetime]): - FORMATS = { - **DatetimeConverterMixin.FORMATS, - 11: "%Y%m%dT%H", - 13: "%Y%m%dT%H%M", - 15: "%Y%m%dT%H%M%S" - } - - @property - def ics_type(self) -> str: - return "DATE-TIME" - - @property - def python_type(self) -> Type[datetime]: - return datetime - - def serialize(self, value: datetime, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: - return self._serialize_dt(value, params, context) - - def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> datetime: - return self._parse_dt(value, params, context) - - -class DateConverter(DatetimeConverterMixin, ValueConverter[date]): - @property - def ics_type(self) -> str: - return "DATE" - - @property - def python_type(self) -> Type[date]: - return date - - def serialize(self, value, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext): - return value.strftime("%Y%m%d") - - def parse(self, value, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext): - return self._parse_dt(value, params, context, warn_no_avail_tz=False).date() - - -class TimeConverter(DatetimeConverterMixin, ValueConverter[time]): - FORMATS = { - 2: "%H", - 4: "%H%M", - 6: "%H%M%S" - } - - @property - def ics_type(self) -> str: - return "TIME" - - @property - def python_type(self) -> Type[time]: - return time - - def serialize(self, value, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext): - return self._serialize_dt(value, params, context, utc_fmt="%H%M%SZ", nonutc_fmt="%H%M%S") - - def parse(self, value, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext): - return self._parse_dt(value, params, context).timetz() - - -class UTCOffsetConverter(ValueConverter[UTCOffset]): - @property - def ics_type(self) -> str: - return "UTC-OFFSET" - - @property - def python_type(self) -> Type[UTCOffset]: - return UTCOffset - - def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> UTCOffset: - match = re.fullmatch(r"(?P\+|-|)(?P[0-9]{2})(?P[0-9]{2})(?P[0-9]{2})?", value) - if not match: - raise ValueError("value '%s' is not a valid UTCOffset") - groups = match.groupdict() - sign = groups.pop("sign") - td = timedelta(**{k: int(v) for k, v in groups.items() if v}) - if sign == "-": - td *= -1 - return UTCOffset(value, td) - - def serialize(self, value: UTCOffset, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: - offset = value.utcoffset(None) - assert offset is not None - seconds = offset.seconds - if seconds < 0: - res = "-" - else: - res = "+" - - # hours - res += '%02d' % (seconds // 3600) - seconds %= 3600 - - # minutes - res += '%02d' % (seconds // 60) - seconds %= 60 - - if seconds: - # seconds - res += '%02d' % seconds - - return res - - -class DurationConverter(ValueConverter[timedelta]): - @property - def ics_type(self) -> str: - return "DURATION" - - @property - def python_type(self) -> Type[timedelta]: - return timedelta - - def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> timedelta: - DAYS = {'D': 1, 'W': 7} - SECS = {'S': 1, 'M': 60, 'H': 3600} - - sign, i = 1, 0 - if value[i] in '-+': - if value[i] == '-': - sign = -1 - i += 1 - if value[i] != 'P': - raise ValueError("Error while parsing %s" % value) - i += 1 - days, secs = 0, 0 - while i < len(value): - if value[i] == 'T': - i += 1 - if i == len(value): - break - j = i - while value[j].isdigit(): - j += 1 - if i == j: - raise ValueError("Error while parsing %s" % value) - val = int(value[i:j]) - if value[j] in DAYS: - days += val * DAYS[value[j]] - DAYS.pop(value[j]) - elif value[j] in SECS: - secs += val * SECS[value[j]] - SECS.pop(value[j]) - else: - raise ValueError("Error while parsing %s" % value) - i = j + 1 - return timedelta(sign * days, sign * secs) - - def serialize(self, value: timedelta, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: - ONE_DAY_IN_SECS = 3600 * 24 - total = abs(int(value.total_seconds())) - days = total // ONE_DAY_IN_SECS - seconds = total % ONE_DAY_IN_SECS - - res = '' - if days: - res += str(days) + 'D' - if seconds: - res += 'T' - if seconds // 3600: - res += str(seconds // 3600) + 'H' - seconds %= 3600 - if seconds // 60: - res += str(seconds // 60) + 'M' - seconds %= 60 - if seconds: - res += str(seconds) + 'S' - - if not res: - res = 'T0S' - if value.total_seconds() >= 0: - return 'P' + res - else: - return '-P%s' % res - - -class PeriodConverter(DatetimeConverterMixin, ValueConverter[Timespan]): - - @property - def ics_type(self) -> str: - return "PERIOD" - - @property - def python_type(self) -> Type[Timespan]: - return Timespan - - def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext): - start, sep, end = value.partition("/") - if not sep: - raise ValueError("PERIOD '%s' must contain the separator '/'") - if end.startswith("P"): # period-start = date-time "/" dur-value - return Timespan(begin_time=self._parse_dt(start, params, context), - duration=DurationConverter.INST.parse(end, params, context)) - else: # period-explicit = date-time "/" date-time - end_params = copy_extra_params(params) # ensure that the first parse doesn't remove TZID also needed by the second call - return Timespan(begin_time=self._parse_dt(start, params, context), - end_time=self._parse_dt(end, end_params, context)) - - def serialize(self, value: Timespan, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: - # note: there are no DATE to DATE / all-day periods - begin = value.get_begin() - if begin is None: - raise ValueError("PERIOD must have a begin timestamp") - if value.get_end_representation() == "duration": - duration = cast(timedelta, value.get_effective_duration()) - return "%s/%s" % ( - self._serialize_dt(begin, params, context), - DurationConverter.INST.serialize(duration, params, context) - ) - else: - end = value.get_effective_end() - if end is None: - raise ValueError("PERIOD must have a end timestamp") - end_params = copy_extra_params(params) - res = "%s/%s" % ( - self._serialize_dt(begin, params, context), - self._serialize_dt(end, end_params, context) - ) - if end_params != params: - raise ValueError("Begin and end time of PERIOD %s must serialize to the same params! " - "Got %s != %s." % (value, params, end_params)) - return res diff --git a/ics/valuetype/generic.py b/ics/valuetype/generic.py deleted file mode 100644 index 4c2cdc22..00000000 --- a/ics/valuetype/generic.py +++ /dev/null @@ -1,144 +0,0 @@ -import base64 -from typing import Type -from urllib.parse import urlparse - -from dateutil.rrule import rrule - -from ics.types import ContextDict, EmptyContext, EmptyParams, ExtraParams, URL -from ics.valuetype.base import ValueConverter - - -class BinaryConverter(ValueConverter[bytes]): - - @property - def ics_type(self) -> str: - return "BINARY" - - @property - def python_type(self) -> Type[bytes]: - return bytes - - def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> bytes: - return base64.b64decode(value) - - def serialize(self, value: bytes, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: - return base64.b64encode(value).decode("ascii") - - -ValueConverter.BY_TYPE[bytearray] = ValueConverter.BY_TYPE[bytes] - - -class BooleanConverter(ValueConverter[bool]): - - @property - def ics_type(self) -> str: - return "BOOLEAN" - - @property - def python_type(self) -> Type[bool]: - return bool - - def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> bool: - if value == "TRUE": - return True - elif value == "FALSE": - return False - else: - value = value.upper() - if value == "TRUE": - return True - elif value == "FALSE": - return False - elif value in ["T", "Y", "YES", "ON", "1"]: - return True - elif value in ["F", "N", "NO", "OFF", "0"]: - return False - else: - raise ValueError("can't interpret '%s' as boolen" % value) - - def serialize(self, value: bool, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: - if value: - return "TRUE" - else: - return "FALSE" - - -class IntegerConverter(ValueConverter[int]): - - @property - def ics_type(self) -> str: - return "INTEGER" - - @property - def python_type(self) -> Type[int]: - return int - - def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> int: - return int(value) - - def serialize(self, value: int, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: - return str(value) - - -class FloatConverter(ValueConverter[float]): - - @property - def ics_type(self) -> str: - return "FLOAT" - - @property - def python_type(self) -> Type[float]: - return float - - def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> float: - return float(value) - - def serialize(self, value: float, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: - return str(value) - - -class RecurConverter(ValueConverter[rrule]): - - @property - def ics_type(self) -> str: - return "RECUR" - - @property - def python_type(self) -> Type[rrule]: - return rrule - - def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> rrule: - # this won't be called unless a class specifies an attribute with type: rrule - raise NotImplementedError("parsing 'RECUR' is not yet supported") # TODO is this a valuetype or a composed object - - def serialize(self, value: rrule, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: - raise NotImplementedError("serializing 'RECUR' is not yet supported") - - -class URIConverter(ValueConverter[URL]): - # TODO URI PARAMs need percent escaping, preventing all illegal characters except for ", in which they also need to wrapped - # TODO URI values also need percent escaping (escaping COMMA characters in URI Lists), but no quoting - - @property - def ics_type(self) -> str: - return "URI" - - @property - def python_type(self) -> Type[URL]: - return URL - - def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> URL: - return urlparse(value) - - def serialize(self, value: URL, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: - if isinstance(value, str): - return value - else: - return value.geturl() - - -class CalendarUserAddressConverter(URIConverter): - - @property - def ics_type(self) -> str: - return "CAL-ADDRESS" diff --git a/ics/valuetype/special.py b/ics/valuetype/special.py deleted file mode 100644 index 60698a59..00000000 --- a/ics/valuetype/special.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import Type - -from ics.geo import Geo -from ics.types import ContextDict, EmptyContext, EmptyParams, ExtraParams -from ics.valuetype.base import ValueConverter - - -class GeoConverter(ValueConverter[Geo]): - - @property - def ics_type(self) -> str: - return "X-GEO" - - @property - def python_type(self) -> Type[Geo]: - return Geo - - def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> Geo: - latitude, sep, longitude = value.partition(";") - if not sep: - raise ValueError("geo must have two float values") - return Geo(float(latitude), float(longitude)) - - def serialize(self, value: Geo, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: - return "%f;%f" % value diff --git a/ics/valuetype/text.py b/ics/valuetype/text.py deleted file mode 100644 index 0a172972..00000000 --- a/ics/valuetype/text.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import Iterable, Iterator, Type - -from ics.types import ContextDict, EmptyContext, EmptyParams, ExtraParams -from ics.utils import next_after_str_escape -from ics.valuetype.base import ValueConverter - - -class TextConverter(ValueConverter[str]): - - @property - def ics_type(self) -> str: - return "TEXT" - - @property - def python_type(self) -> Type[str]: - return str - - def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: - return self.unescape_text(value) - - def serialize(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: - return self.escape_text(value) - - def split_value_list(self, values: str) -> Iterable[str]: - it = iter(values.split(",")) - for val in it: - while val.endswith("\\") and not val.endswith("\\\\"): - val += "," + next_after_str_escape(it, full_str=values) - yield val - - # def join_value_list(self, values: Iterable[str]) -> str: - # return ",".join(values) # TODO warn about missing escapes - - @classmethod - def escape_text(cls, string: str) -> str: - return string.translate( - {ord("\\"): "\\\\", - ord(";"): "\\;", - ord(","): "\\,", - ord("\n"): "\\n", - ord("\r"): "\\r"}) - - @classmethod - def unescape_text(cls, string: str) -> str: - return "".join(cls.unescape_text_iter(string)) - - @classmethod - def unescape_text_iter(cls, string: str) -> Iterator[str]: - it = iter(string) - for c1 in it: - if c1 == "\\": - c2 = next_after_str_escape(it, full_str=string) - if c2 == ";": - yield ";" - elif c2 == ",": - yield "," - elif c2 == "n" or c2 == "N": - yield "\n" - elif c2 == "r" or c2 == "R": - yield "\r" - elif c2 == "\\": - yield "\\" - else: - raise ValueError("can't handle escaped character '%s'" % c2) - elif c1 in ";,\n\r": - raise ValueError("unescaped character '%s' in TEXT value" % c1) - else: - yield c1 diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index 281dd2ec..00000000 --- a/mypy.ini +++ /dev/null @@ -1,3 +0,0 @@ -[mypy] -ignore_missing_imports = True -check_untyped_defs = True diff --git a/pyproject.toml b/pyproject.toml index fcb8f1f7..17d3c9df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ics" -version = "0.9.2-dev" +version = "0.8.0-dev" description = "Pythonic iCalendar (RFC 5545) Parser" authors = ["Nikita Marchant ", "Niko Fink "] license = "Apache-2.0" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 10b635b6..00000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -python-dateutil -tatsu>4.2 -attrs>=19.2 -importlib_resources diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 657c4d9f..00000000 --- a/setup.cfg +++ /dev/null @@ -1,47 +0,0 @@ -[tool:pytest] -python_files = *.py -flakes-ignore = - UnusedImport - UndefinedName -pep8ignore = - tests/*.py ALL - doc/_themes/flask_theme_support.py ALL - E501 - E128 - F403 - F401 - E261 - E265 - W503 - E701 - E251 - E127 - E731 - E704 - -norecursedirs = ve .git .eggs .cache ics.egg-info doc -testpaths = ics tests -addopts = --flakes --pep8 - - - -[flake8] -ignore = E128,E261,E265,E501,F403,F401,W503 -exclude = doc/,ve/,ve3/ - -[metadata] -license-file = LICENSE.rst - -[check-manifest] -ignore = - .travis.yml - doc/* - doc - requirements*.txt - test - dev/* - dev - .coveragerc - -[wheel] -universal = 1 diff --git a/setup.py b/setup.py deleted file mode 100755 index d6536127..00000000 --- a/setup.py +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env python -import sys - -from setuptools import setup -from setuptools.command.test import test as TestCommand - -from ics.__meta__ import __author__, __license__, __title__, __version__ - -with open("requirements.txt") as f: - install_requires = [line for line in f if line and line[0] not in "#-"] - -with open("dev/requirements-test.txt") as f: - tests_require = [line for line in f if line and line[0] not in "#-"] - - -class PyTest(TestCommand): - - def finalize_options(self): - TestCommand.finalize_options(self) - self.test_args = [ - '--cov', - 'ics', - 'ics/', - 'tests/' - ] - self.test_suite = True - - def run_tests(self): - # import here, cause outside the eggs aren't loaded - import pytest - errno = pytest.main(self.test_args) - sys.exit(errno) - - -def readme(): - with open('README.rst', encoding='utf-8') as f: - return f.read() - - -setup( - name=__title__, - version=__version__, - description='Python icalendar (rfc5545) parser', - long_description=readme(), - keywords='ics icalendar calendar event todo rfc5545 parser pythonic', - classifiers=[ - 'Development Status :: 4 - Beta', - 'Topic :: Software Development :: Libraries', - 'Topic :: Software Development :: Libraries :: Python Modules', - 'Intended Audience :: Developers', - 'Topic :: Office/Business :: Scheduling', - 'License :: OSI Approved :: Apache Software License', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Typing :: Typed', - - ], - url='http://github.com/C4ptainCrunch/ics.py', - author=__author__, - author_email='nikita.marchant@gmail.com', - install_requires=install_requires, - license=__license__, - packages=['ics'], - include_package_data=True, - cmdclass={'test': PyTest}, - tests_require=tests_require, - test_suite="py.test", - zip_safe=True, -) diff --git a/src/ics/__init__.py b/src/ics/__init__.py index a7dd521d..91a2436a 100644 --- a/src/ics/__init__.py +++ b/src/ics/__init__.py @@ -38,4 +38,4 @@ def load_converters(): "__version__" ] -__version__ = "0.9.2-dev" +__version__ = "0.8.0-dev" diff --git a/src/ics/converter/special.py b/src/ics/converter/special.py index 2d96283d..aca62652 100644 --- a/src/ics/converter/special.py +++ b/src/ics/converter/special.py @@ -26,7 +26,8 @@ def populate(self, component: "Component", item: ContainerItem, context: Context item = item.clone([ line for line in item if - not line.name.startswith("X-") and not line.name == "SEQUENCE" + not line.name.startswith("X-") and + not line.name == "SEQUENCE" ]) fake_file = StringIO() diff --git a/tests/alarm.py b/tests/alarm.py deleted file mode 100644 index 1263cb2b..00000000 --- a/tests/alarm.py +++ /dev/null @@ -1,229 +0,0 @@ -import unittest -from datetime import datetime, timedelta - -import attr -from dateutil.tz import UTC as tzutc - -from ics.alarm import AudioAlarm, DisplayAlarm -from ics.alarm.base import BaseAlarm -from ics.alarm.custom import CustomAlarm -from ics.alarm.email import EmailAlarm -from ics.alarm.none import NoneAlarm -from ics.grammar.parse import ContentLine -from ics.icalendar import Calendar -from .fixture import cal21, cal22, cal23, cal24, cal25, cal35, cal36 - -CRLF = "\r\n" - - -class FakeAlarm(BaseAlarm): - @property - def action(self): - return "FAKE" - - -class TestAlarm(unittest.TestCase): - def test_alarm_timedelta_trigger(self): - a = FakeAlarm(trigger=timedelta(minutes=15)) - self.assertEqual(15 * 60, a.trigger.total_seconds()) - - def test_alarm_datetime_trigger(self): - alarm_time = datetime(year=2016, month=1, day=1, hour=0, minute=0, second=0) - a = FakeAlarm(trigger=alarm_time) - self.assertEqual(alarm_time, a.trigger) - - def test_alarm_repeat(self): - a = FakeAlarm( - trigger=timedelta(minutes=15), repeat=2, duration=timedelta(minutes=10) - ) - self.assertEqual(15 * 60, a.trigger.total_seconds()) - self.assertEqual(2, a.repeat) - self.assertEqual(10 * 60, a.duration.total_seconds()) - - def test_alarm_invalid_repeat(self): - with self.assertRaises(ValueError): - FakeAlarm( - trigger=timedelta(minutes=15), repeat=-2, duration=timedelta(minutes=10) - ) - - def test_alarm_invalid_duration(self): - with self.assertRaises(ValueError): - FakeAlarm( - trigger=timedelta(minutes=15), repeat=2, duration=timedelta(minutes=-10) - ) - - def test_alarm_missing_duration(self): - with self.assertRaises(ValueError): - FakeAlarm(trigger=timedelta(minutes=15), repeat=2) - - def test_alarm_timedelta_trigger_output(self): - a = FakeAlarm(trigger=timedelta(minutes=15)) - - desired_output = CRLF.join( - ["BEGIN:VALARM", "ACTION:FAKE", "TRIGGER:PT15M", "END:VALARM"] - ) - - self.assertEqual(desired_output, str(a)) - - def test_alarm_datetime_trigger_output(self): - alarm_time = datetime(year=2016, month=1, day=1, hour=0, minute=0, second=0, tzinfo=tzutc) - a = FakeAlarm(trigger=alarm_time) - - desired_output = CRLF.join( - [ - "BEGIN:VALARM", - "ACTION:FAKE", - "TRIGGER;VALUE=DATE-TIME:20160101T000000Z", - "END:VALARM", - ] - ) - - self.assertEqual(desired_output, str(a)) - - def test_alarm_repeat_duration_output(self): - a = FakeAlarm( - trigger=timedelta(minutes=15), repeat=2, duration=timedelta(minutes=10) - ) - - desired_output = CRLF.join( - [ - "BEGIN:VALARM", - "ACTION:FAKE", - "DURATION:PT10M", - "REPEAT:2", - "TRIGGER:PT15M", - "END:VALARM", - ] - ) - - self.assertEqual(desired_output, str(a)) - - -class TestDisplayAlarm(unittest.TestCase): - def test_alarm(self): - test_description = "Test description" - a = DisplayAlarm(trigger=timedelta(minutes=15), display_text=test_description) - self.assertEqual(test_description, a.display_text) - - def test_alarm_output(self): - a = DisplayAlarm(trigger=timedelta(minutes=15), display_text="Test description") - - desired_output = CRLF.join( - [ - "BEGIN:VALARM", - "ACTION:DISPLAY", - "DESCRIPTION:Test description", - "TRIGGER:PT15M", - "END:VALARM", - ] - ) - - self.assertEqual(desired_output, str(a)) - - def test_alarm_without_repeat_extraction(self): - c = Calendar(cal21) - e = next(iter(c.events)) - assert isinstance(e.alarms, list) - a = e.alarms[0] - self.assertEqual(a.trigger, timedelta(hours=1)) - self.assertIsNone(a.repeat) - self.assertIsNone(a.duration) - self.assertEqual(a.display_text, "Event reminder") - - def test_alarm_with_repeat_extraction(self): - c = Calendar(cal22) - a = next(iter(c.events)).alarms[0] - self.assertEqual(a.trigger, timedelta(hours=1)) - self.assertEqual(a.repeat, 2) - self.assertEqual(a.duration, timedelta(minutes=10)) - self.assertEqual(a.display_text, "Event reminder") - - def test_alarm_without_repeat_datetime_trigger_extraction(self): - c = Calendar(cal23) - a = next(iter(c.events)).alarms[0] - - alarm_time = datetime(year=2016, month=1, day=1, hour=0, minute=0, second=0, tzinfo=tzutc) - self.assertEqual(a.trigger, alarm_time) - self.assertIsNone(a.repeat) - self.assertIsNone(a.duration) - self.assertEqual(a.display_text, "Event reminder") - - -class TestAudioAlarm(unittest.TestCase): - def test_alarm(self): - a = AudioAlarm(trigger=timedelta(minutes=15)) - self.assertEqual("AUDIO", a.action) - - def test_plain_repr(self): - a = AudioAlarm(trigger=timedelta(minutes=15)) - self.assertEqual(repr(a), "") - - def test_alarm_output(self): - attach = "ftp://example.com/pub/sounds/bell-01.aud" - attach_params = {"FMTTYPE": ["audio/basic"]} - a = AudioAlarm(trigger=timedelta(minutes=15)) - a.sound = ContentLine("ATTACH", value=attach, params=attach_params) - - desired_output = CRLF.join( - [ - "BEGIN:VALARM", - "ACTION:AUDIO", - "ATTACH;FMTTYPE=audio/basic:{0}".format(attach), - "TRIGGER:PT15M", - "END:VALARM", - ] - ) - - self.assertEqual(desired_output, str(a)) - - def test_alarm_without_attach_extraction(self): - c = Calendar(cal24) - a = next(iter(c.events)).alarms[0] - alarm_time = datetime(year=2016, month=1, day=1, hour=0, minute=0, second=0, tzinfo=tzutc) - - self.assertEqual(a.action, "AUDIO") - self.assertEqual(a.trigger, alarm_time) - self.assertIsNone(a.repeat) - self.assertIsNone(a.duration) - self.assertIsNone(a.sound) - - def test_alarm_with_attach_extraction(self): - c = Calendar(cal25) - a = next(iter(c.events)).alarms[0] - alarm_time = datetime(year=2016, month=1, day=1, hour=0, minute=0, second=0, tzinfo=tzutc) - - self.assertEqual(a.action, "AUDIO") - self.assertEqual(a.trigger, alarm_time) - self.assertIsNone(a.repeat) - self.assertIsNone(a.duration) - self.assertEqual(a.sound.value, "ftp://example.com/pub/sounds/bell-01.aud") - self.assertIn("FMTTYPE", a.sound.params.keys()) - self.assertEqual(1, len(a.sound.params["FMTTYPE"])) - self.assertEqual("audio/basic", a.sound.params["FMTTYPE"][0]) - - -def test_none(): - c = Calendar(cal35) - a = next(iter(c.events)).alarms[0] - assert isinstance(a, NoneAlarm) - - -def test_custom(): - c = Calendar(cal36) - a = next(iter(c.events)).alarms[0] - assert isinstance(a, CustomAlarm) - assert a.action == "YOLO" - - -def test_custom_back_forth(): - c = Calendar(cal36) - c1 = Calendar(str(c)) - c.events[0].dtstamp = c1.events[0].dtstamp = datetime.now() - assert attr.asdict(c) == attr.asdict(c1) - assert c == c1 - - -class TestEmailAlarm(unittest.TestCase): - def test_alarm(self): - a = EmailAlarm(trigger=timedelta(minutes=15)) - self.assertEqual("EMAIL", a.action) diff --git a/tests/calendar.py b/tests/calendar.py deleted file mode 100644 index 2af5719a..00000000 --- a/tests/calendar.py +++ /dev/null @@ -1,233 +0,0 @@ -import unittest -from collections.abc import Iterable -from datetime import datetime - -import attr -from dateutil.tz import UTC as tzutc, gettz - -from ics.event import Event -from ics.grammar.parse import Container -from ics.icalendar import Calendar -from ics.todo import Todo -from .fixture import cal1, cal10, cal12, cal14, cal34 - - -class TestCalendar(unittest.TestCase): - fixtures = [cal1, cal10, cal12] - - def test_init(self): - c = Calendar(creator='tests') - self.assertEqual(c.creator, 'tests') - self.assertSequenceEqual(c.events, []) - self.assertSequenceEqual(c.todos, []) - self.assertEqual(c.method, None) - self.assertEqual(c.scale, None) - self.assertEqual(c.extra, Container(name='VCALENDAR')) - self.assertEqual(c._timezones, {}) - - def test_selfload(self): - def filter(attr, value): - return not attr.name.startswith("_classmethod") and not attr.name == "_timezones" - - for fix in self.fixtures: - c = Calendar(fix) - d = Calendar(str(c)) - self.assertEqual(attr.asdict(c, filter=filter), attr.asdict(d, filter=filter)) - self.assertEqual(c, d) - self.assertEqual(c.events, d.events) - self.assertEqual(c.todos, d.todos) - - e = Calendar(str(d)) - # cannot compare str(c) and str(d) because times are encoded differently - self.assertEqual(str(d), str(e)) - - def test_repr(self): - c = Calendar() - self.assertEqual(c.__repr__(), '') - - c.events.append(Event()) - c.todos.append(Todo()) - self.assertEqual(c.__repr__(), '') - - c.events.append(Event()) - c.todos.append(Todo()) - self.assertEqual(c.__repr__(), '') - - def test_iter(self): - for fix in self.fixtures: - c = Calendar(imports=fix) - s = str(c) - self.assertIsInstance(c, Iterable) - i_with_no_lr = map(lambda x: x.rstrip('\n'), c) - self.assertSequenceEqual(s.split('\n'), list(i_with_no_lr)) - - def test_eq(self): - c0, c1 = Calendar(), Calendar() - e = Event() - - c0.events.append(e) - c1.events.append(e) - - self.assertEqual(c0, c1) - - t = Todo() - - c0.todos.append(t) - c1.todos.append(t) - - def test_neq_len(self): - c0, c1 = Calendar(), Calendar() - e1 = Event() - e2 = Event() - - c0.events.append(e1) - c0.events.append(e2) - - c1.events.append(e1) - - self.assertNotEqual(c0, c1) - - t1 = Todo() - t2 = Todo() - - c0.todos.append(t1) - c0.todos.append(t2) - - c1.todos.append(t1) - - self.assertNotEqual(c0, c1) - - def test_eq_len(self): - c0, c1 = Calendar(), Calendar() - e = Event() - - c0.events.append(e) - c1.events.append(e) - - self.assertEqual(c0, c1) - - t = Todo() - - c0.todos.append(t) - c1.todos.append(t) - - self.assertEqual(c0, c1) - - def test_neq_events(self): - c0, c1 = Calendar(), Calendar() - e0, e1 = Event(), Event() - - c0.events.append(e0) - c1.events.append(e1) - - self.assertNotEqual(c0, c1) - - def test_neq_todos(self): - c0, c1 = Calendar(), Calendar() - t0, t1 = Todo(), Todo() - - c0.events.append(t0) - c1.events.append(t1) - - self.assertNotEqual(c0, c1) - - def test_neq_creator(self): - c0, c1 = Calendar(), Calendar(creator="test") - self.assertNotEqual(c0, c1) - - def test_creator(self): - - c0 = Calendar() - c1 = Calendar() - c0.creator = u'42' - with self.assertRaises(TypeError): - c1.creator = 42 - - self.assertEqual(c0.creator, u'42') - - def test_existing_creator(self): - c = Calendar(cal1) - self.assertEqual(c.creator, u'-//Apple Inc.//Mac OS X 10.9//EN') - - c.creator = u"apple_is_a_fruit" - self.assertEqual(c.creator, u"apple_is_a_fruit") - - def test_scale(self): - - c = Calendar(cal10) - - self.assertEqual(c.scale, u'georgian') - - def test_version(self): - - c = Calendar(cal10) - self.assertEqual(u'2.0', c.version) - lines = str(c).splitlines() - self.assertEqual(lines[:3], cal10.strip().splitlines()[:3]) - self.assertEqual("VERSION:2.0", lines[1]) - self.assertIn("PRODID", lines[2]) - - c = Calendar(cal14) - self.assertEqual(u'42', c.version) - - def test_events_setter(self): - - c = Calendar(cal1) - e = Event() - c.events = [e] - - self.assertEqual(c.events, [e]) - - def test_todos_setter(self): - - c = Calendar(cal1) - t = Todo() - c.todos = [t] - - self.assertEqual(c.todos, [t]) - - def test_clone(self): - c0 = Calendar() - e = Event() - t = Todo() - c0.events.append(e) - c0.todos.append(t) - c1 = c0.clone() - - self.assertEqual(c0.events, c1.events) - self.assertEqual(c0.todos, c1.todos) - self.assertEqual(c0, c1) - - def test_multiple_calendars(self): - - with self.assertRaises(TypeError): - Calendar() + Calendar() - - def test_init_int(self): - - with self.assertRaises(TypeError): - Calendar(42) - - def test_imports(self): - c = Calendar(cal1) - self.assertEqual(c.creator, '-//Apple Inc.//Mac OS X 10.9//EN') - self.assertEqual(c.method, 'PUBLISH') - e = next(iter(c.events)) - self.assertFalse(e.all_day) - tz = gettz('Europe/Brussels') - self.assertEqual(datetime(2013, 10, 29, 10, 30, tzinfo=tz), e.begin) - self.assertEqual(datetime(2013, 10, 29, 11, 30, tzinfo=tz), e.end) - self.assertEqual(1, len(c.events)) - t = next(iter(c.todos)) - self.assertEqual(datetime(2018, 2, 18, 15, 47, tzinfo=tzutc), t.dtstamp) - self.assertEqual('Uid', t.uid) - self.assertEqual(1, len(c.todos)) - - def test_multiple(self): - cals = Calendar.parse_multiple(cal34) - self.assertEqual(len(cals), 2) - - e1 = list(cals[0].events)[0] - self.assertEqual(e1.name, "a") - e2 = list(cals[1].events)[0] - self.assertEqual(e2.name, "b") diff --git a/tests/component.py b/tests/component.py deleted file mode 100644 index 8308ae21..00000000 --- a/tests/component.py +++ /dev/null @@ -1,235 +0,0 @@ -import copy -import unittest - -from ics.component import Component -from ics.icalendar import Calendar -from ics.grammar.parse import Container, ContentLine -from ics.parsers.parser import Parser, option -from ics.serializers.serializer import Serializer - -from .fixture import cal2 - -fix1 = "BEGIN:BASETEST\r\nATTR:FOOBAR\r\nEND:BASETEST" - -fix2 = "BEGIN:BASETEST\r\nATTR:FOO\r\nATTR2:BAR\r\nEND:BASETEST" - - -class TestComponent(unittest.TestCase): - - def test_valueerror(self): - - with self.assertRaises(ValueError): - Calendar(cal2) - - def test_bad_type(self): - container = Container(name='VINVALID') - with self.assertRaises(ValueError): - Calendar._from_container(container) - - def test_base(self): - assert CT4.Meta.name == "TEST" - e = CT4.Meta.parser.get_parsers() - assert len(e) == 1 - - def test_parser(self): - c = CT1() - c.some_attr = "foobar" - expected = fix1 - self.assertEqual(str(c), expected) - - def test_2parsers(self): - c = CT2() - c.some_attr = "foo" - c.some_attr2 = "bar" - expected = fix2 - self.assertEqual(str(c), expected) - - def test_empty_input(self): - cont = Container("TEST") - c = CT1._from_container(cont) - self.assertEqual(c.extra.name, "TEST") - self.assertEqual(c.some_attr, "biiip") - - def test_no_match_input(self): - cont = Container("TEST") - cont.append(ContentLine(name="NOMATCH", value="anything")) - cont2 = copy.deepcopy(cont) - - c = CT1._from_container(cont) - self.assertEqual(c.extra.name, "TEST") - self.assertEqual(c.some_attr, "biiip") - self.assertEqual(cont2, c.extra) - - def test_input(self): - cont = Container("TEST") - cont.append(ContentLine(name="ATTR", value="anything")) - - c = CT1._from_container(cont) - self.assertEqual(c.extra.name, "TEST") - self.assertEqual(c.some_attr, "anything") - self.assertEqual(Container("TEST"), c.extra) - - def test_input_plus_extra(self): - cont = Container("TEST") - cont.append(ContentLine(name="ATTR", value="anything")) - cont.append(ContentLine(name="PLOP", value="plip")) - - unused = Container("TEST") - unused.append(ContentLine(name="PLOP", value="plip")) - - c = CT1._from_container(cont) - self.assertEqual(c.extra.name, "TEST") - self.assertEqual(c.some_attr, "anything") - self.assertEqual(unused, c.extra) - - def test_required_raises(self): - cont = Container("TEST") - cont.append(ContentLine(name="PLOP", value="plip")) - - with self.assertRaises(ValueError): - CT2._from_container(cont) - - def test_required(self): - cont = Container("TEST") - cont.append(ContentLine(name="ATTR", value="anything")) - cont.append(ContentLine(name="PLOP", value="plip")) - - unused = Container("TEST") - unused.append(ContentLine(name="PLOP", value="plip")) - - c = CT2._from_container(cont) - self.assertEqual(c.some_attr, "anything") - self.assertEqual(unused, c.extra) - - def test_multiple_non_allowed(self): - cont = Container("TEST") - cont.append(ContentLine(name="ATTR", value="anything")) - cont.append(ContentLine(name="ATTR", value="plip")) - - with self.assertRaises(ValueError): - CT1._from_container(cont) - - def test_multiple(self): - cont = Container("TEST") - cont.append(ContentLine(name="ATTR", value="anything")) - cont.append(ContentLine(name="ATTR", value="plip")) - - c = CT3._from_container(cont) - - self.assertEqual(c.some_attr, "plip, anything") - self.assertEqual(Container("TEST"), c.extra) - - def test_multiple_fail(self): - cont = Container("TEST") - - c = CT3._from_container(cont) - - self.assertEqual(c.some_attr, "biiip") - self.assertEqual(Container("TEST"), c.extra) - - def test_multiple_unique(self): - cont = Container("TEST") - cont.append(ContentLine(name="ATTR", value="anything")) - - c = CT3._from_container(cont) - - self.assertEqual(c.some_attr, "anything") - self.assertEqual(Container("TEST"), c.extra) - - def test_multiple_unique_required(self): - cont = Container("TEST") - cont.append(ContentLine(name="OTHER", value="anything")) - - with self.assertRaises(ValueError): - CT4._from_container(cont) - - -class ComponentBaseTest(Component): - class Meta: - name = "TEST" - - def __init__(self): - self.some_attr = "biiip" - self.some_attr2 = "baaaaaaaaaaap" - self.extra = Container('BASETEST') - - -# 1 -class CT1Parser(Parser): - def parse_attr(test, line): - if line: - test.some_attr = line.value - - -class CT1Serializer(Serializer): - def serialize_some_attr(test, container): - if test.some_attr: - container.append(ContentLine('ATTR', value=test.some_attr.upper())) - - -class CT1(ComponentBaseTest): - class Meta: - name = "TEST" - parser = CT1Parser - serializer = CT1Serializer - - -# 2 -class CT2Parser(Parser): - @option(required=True) - def parse_attr(test, line): - test.some_attr = line.value - - -class CT2Serializer(Serializer): - def serialize_some_attr2(test, container): - if test.some_attr: - container.append(ContentLine('ATTR', value=test.some_attr.upper())) - - def serialize_some_attr2bis(test, container): - if test.some_attr2: - container.append(ContentLine('ATTR2', value=test.some_attr2.upper())) - - -class CT2(ComponentBaseTest): - class Meta: - name = "TEST" - parser = CT2Parser - serializer = CT2Serializer - - -# 3 -class CT3Parser(Parser): - @option(multiple=True) - def parse_attr(test, line_list): - if line_list: - test.some_attr = ", ".join(map(lambda x: x.value, line_list)) - - -class CT3Serializer(Serializer): - pass - - -class CT3(ComponentBaseTest): - class Meta: - name = "TEST" - parser = CT3Parser - serializer = CT3Serializer - - -# 4 -class CT4Parser(Parser): - @option(required=True, multiple=True) - def parse_attr(test, line_list): - test.some_attr = ", ".join(map(lambda x: x.value, line_list)) - - -class CT4Serializer(Serializer): - pass - - -class CT4(ComponentBaseTest): - class Meta: - name = "TEST" - parser = CT4Parser - serializer = CT4Serializer diff --git a/tests/duration.py b/tests/duration.py deleted file mode 100644 index 9b41fd55..00000000 --- a/tests/duration.py +++ /dev/null @@ -1,17 +0,0 @@ -from ics.utils import parse_duration -from datetime import timedelta - - -def test_simple(): - s = "PT30M" - assert parse_duration(s) == timedelta(minutes=30) - - -def test_negative(): - s = "-PT30M" - assert parse_duration(s) == timedelta(minutes=-30) - - -def test_no_sign(): - s = "P0DT9H0M0S" - assert parse_duration(s) == timedelta(hours=9) diff --git a/tests/event.py b/tests/event.py deleted file mode 100644 index fee4b131..00000000 --- a/tests/event.py +++ /dev/null @@ -1,570 +0,0 @@ -import os -import unittest -from datetime import datetime as dt, timedelta as td - -import pytest -from dateutil.tz import UTC as tzutc -from ics.grammar.parse import Container - -from ics.attendee import Attendee, Organizer -from ics.event import Event -from ics.icalendar import Calendar -from .fixture import (cal12, cal13, cal15, cal16, cal17, cal18, cal19, - cal19bis, cal20, cal32, cal33_1, cal33_2, cal33_3, - cal33_4, cal33_5) - -CRLF = "\r\n" - -EVENT_A = Event(summary="a") -EVENT_B = Event(summary="b") -EVENT_M = Event(summary="m") -EVENT_Z = Event(summary="z") -EVENT_A.created = EVENT_B.created = EVENT_M.created = EVENT_Z.created = dt.now() - - -class TestEvent(unittest.TestCase): - - def test_event(self): - e = Event(begin=dt.fromtimestamp(0), end=dt.fromtimestamp(20)) - self.assertEqual(e.begin.timestamp(), 0) - self.assertEqual(e.end.timestamp(), 20) - self.assertTrue(e.has_explicit_end) - self.assertFalse(e.all_day) - - f = Event(begin=dt.fromtimestamp(10), end=dt.fromtimestamp(30)) - self.assertTrue(e < f) - self.assertTrue(e <= f) - self.assertTrue(f > e) - self.assertTrue(f >= e) - - def test_event_with_duration(self): - c = Calendar(cal12) - e = next(iter(c.events)) - self.assertEqual(e.duration, td(1, 3600)) - self.assertEqual(e.end - e.begin, td(1, 3600)) - - def test_event_with_geo(self): - c = Calendar(cal12) - e = list(c.events)[0] - self.assertEqual(e.geo, (40.779897, -73.968565)) - - def test_not_duration_and_end(self): - with self.assertRaises(ValueError): - Calendar(cal13) - - def test_duration_output(self): - e = Event(begin=dt.utcfromtimestamp(0), duration=td(1, 23)) - lines = str(e).splitlines() - self.assertIn('DTSTART:19700101T000000', lines) - self.assertIn('DURATION:P1DT23S', lines) - - def test_duration_output_utc(self): - e = Event(begin=dt.fromtimestamp(0, tz=tzutc), duration=td(days=8, hours=1, minutes=2, seconds=3)) - lines = str(e).splitlines() - self.assertIn('DTSTART:19700101T000000Z', lines) - self.assertIn('DURATION:P8DT1H2M3S', lines) - - def test_geo_output(self): - e = Event(geo=(40.779897, -73.968565)) - lines = str(e).splitlines() - self.assertIn('GEO:40.779897;-73.968565', lines) - - def test_init_duration_end(self): - with self.assertRaises(ValueError): - Event(summary="plop", begin=dt.fromtimestamp(0), end=dt.fromtimestamp(10), duration=td(1)) - - def test_end_before_begin(self): - e = Event(begin=dt(2013, 10, 10)) - with self.assertRaises(ValueError): - e.end = dt(1999, 10, 10) - - def test_begin_after_end(self): - e = Event(begin=dt(1999, 10, 9), end=dt(1999, 10, 10)) - with self.assertRaises(ValueError): - e.begin = dt(2013, 10, 10) - - def test_plain_repr(self): - self.assertEqual("", repr(Event())) - - def test_all_day_repr(self): - e = Event(summary='plop', begin=dt(1999, 10, 10)) - e.make_all_day() - self.assertEqual("", repr(e)) - self.assertEqual(dt(1999, 10, 11), e.end) - - def test_name_repr(self): - e = Event(summary='plop') - self.assertEqual("", repr(e)) - - def test_repr(self): - e = Event(summary='plop', begin=dt(1999, 10, 10)) - self.assertEqual("", repr(e)) - - def test_init(self): - e = Event() - - self.assertEqual(td(0), e.duration) - self.assertEqual(None, e.end) - self.assertEqual(None, e.begin) - self.assertEqual('second', e.timespan.precision) - self.assertNotEqual(None, e.uid) - self.assertEqual(None, e.description) - self.assertEqual(None, e.created) - self.assertEqual(None, e.last_modified) - self.assertEqual(None, e.location) - self.assertEqual(None, e.geo) - self.assertEqual(None, e.url) - self.assertEqual(Container(name='VEVENT'), e.extra) - self.assertEqual(None, e.status) - self.assertEqual(None, e.organizer) - - def test_has_explicit_end(self): - e = Event() - self.assertFalse(e.has_explicit_end) - e = Event(begin=dt(1993, 5, 24), duration=td(days=2)) - self.assertTrue(e.has_explicit_end) - e = Event(begin=dt(1993, 5, 24), end=dt(1999, 10, 11)) - self.assertTrue(e.has_explicit_end) - e = Event(begin=dt(1993, 5, 24)) - e.make_all_day() - self.assertFalse(e.has_explicit_end) - - def test_duration(self): - e = Event() - self.assertEqual(e.duration, td()) - - e1 = Event(begin=dt(1993, 5, 24)) - e1.make_all_day() - self.assertEqual(e1.duration, td(days=1)) - - e2 = Event(begin=dt(1993, 5, 24), end=dt(1993, 5, 30)) - self.assertEqual(e2.duration, td(days=6)) - - e3 = Event(begin=dt(1993, 5, 24), duration=td(minutes=1)) - self.assertEqual(e3.duration, td(minutes=1)) - - e4 = Event(begin=dt(1993, 5, 24)) - self.assertEqual(e4.duration, td(0)) - - e5 = Event(begin=dt(1993, 5, 24)) - e5.duration = {'days': 6, 'hours': 2} - self.assertEqual(e5.end, dt(1993, 5, 30, 2, 0)) - self.assertEqual(e5.duration, td(hours=146)) - - def test_geo(self): - e = Event() - self.assertIsNone(e.geo) - - e1 = Event(geo=(40.779897, -73.968565)) - self.assertEqual(e1.geo, (40.779897, -73.968565)) - - e2 = Event(geo={'latitude': 40.779897, 'longitude': -73.968565}) - self.assertEqual(e2.geo, (40.779897, -73.968565)) - - def test_attendee(self): - a = Attendee(email='email@email.com') - line = str(a) - self.assertIn("ATTENDEE;CN=email@email.com:mailto:email@email.com", line) - - a2 = Attendee(email='email@email.com', common_name='Email') - line = str(a2) - self.assertIn("ATTENDEE;CN=Email:mailto:email@email.com", line) - - a3 = Attendee( - email="email@email.com", - common_name="Email", - partstat="ACCEPTED", - role="CHAIR", - ) - line = str(a3) - self.assertIn( - "ATTENDEE;CN=Email;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:email@email.com", - line, - ) - - def test_add_attendees(self): - e = Event() - a = Attendee(email='email@email.com') - e.add_attendee(a) - lines = str(e).splitlines() - self.assertIn("ATTENDEE;CN=email@email.com:mailto:email@email.com", lines) - - def test_organizer(self): - e = Event() - e.organizer = Organizer(email='email@email.com', common_name='Mister Email') - lines = str(e).splitlines() - self.assertIn("ORGANIZER;CN=Mister Email:mailto:email@email.com", lines) - - def test_always_uid(self): - e = Event() - e.uid = None - self.assertIn('UID:', str(e)) - - def test_cmp_other(self): - with self.assertRaises(TypeError): - Event() < 1 - with self.assertRaises(TypeError): - Event() > 1 - with self.assertRaises(TypeError): - Event() <= 1 - with self.assertRaises(TypeError): - Event() >= 1 - - def test_cmp_by_name(self): - self.assertGreater(EVENT_Z, EVENT_A) - self.assertGreaterEqual(EVENT_Z, EVENT_A) - self.assertGreaterEqual(EVENT_M, EVENT_M) - - self.assertLess(EVENT_A, EVENT_Z) - self.assertLessEqual(EVENT_A, EVENT_Z) - self.assertLessEqual(EVENT_M, EVENT_M) - - def test_cmp_by_name_fail(self): - self.assertFalse(EVENT_A > EVENT_Z) - self.assertFalse(EVENT_A >= EVENT_Z) - - self.assertFalse(EVENT_Z < EVENT_A) - self.assertFalse(EVENT_Z <= EVENT_A) - - def test_cmp_by_name_fail_not_equal(self): - self.assertFalse(EVENT_A > EVENT_A) - self.assertFalse(EVENT_B < EVENT_B) - - def test_cmp_by_start_time(self): - ev1 = Event(begin=dt(2018, 6, 29, 6)) - ev2 = Event(begin=dt(2018, 6, 29, 7)) - self.assertLess(ev1, ev2) - self.assertGreaterEqual(ev2, ev1) - self.assertLessEqual(ev1, ev2) - self.assertGreater(ev2, ev1) - - def test_cmp_by_start_time_with_end_time(self): - ev1 = Event(begin=dt(2018, 6, 29, 5), end=dt(2018, 6, 29, 7)) - ev2 = Event(begin=dt(2018, 6, 29, 6), end=dt(2018, 6, 29, 8)) - ev3 = Event(begin=dt(2018, 6, 29, 6)) - self.assertLess(ev1, ev2) - self.assertGreaterEqual(ev2, ev1) - self.assertLessEqual(ev1, ev2) - self.assertGreater(ev2, ev1) - self.assertLess(ev3, ev2) - self.assertGreaterEqual(ev2, ev3) - self.assertLessEqual(ev3, ev2) - self.assertGreater(ev2, ev3) - - def test_cmp_by_end_time(self): - ev1 = Event(begin=dt(2018, 6, 29, 6), end=dt(2018, 6, 29, 7)) - ev2 = Event(begin=dt(2018, 6, 29, 6), end=dt(2018, 6, 29, 8)) - self.assertLess(ev1, ev2) - self.assertGreaterEqual(ev2, ev1) - self.assertLessEqual(ev1, ev2) - self.assertGreater(ev2, ev1) - - def test_unescape_summary(self): - c = Calendar(cal15) - e = next(iter(c.events)) - self.assertEqual(e.summary, "Hello, \n World; This is a backslash : \\ and another new \n line") - - def test_unescapte_texts(self): - c = Calendar(cal17) - e = next(iter(c.events)) - self.assertEqual(e.summary, "Some special ; chars") - self.assertEqual(e.location, "In, every text field") - self.assertEqual(e.description, "Yes, all of them;") - - def test_escape_output(self): - e = Event() - - e.summary = "Hello, with \\ special; chars and \n newlines" - e.location = "Here; too" - e.description = "Every\nwhere ! Yes, yes !" - e.dtstamp = dt(2013, 1, 1) - e.uid = "empty-uid" - - eq = list(sorted([ - "BEGIN:VEVENT", - "DTSTAMP:20130101T000000", - "DESCRIPTION:Every\\nwhere ! Yes\\, yes !", - "LOCATION:Here\\; too", - "SUMMARY:Hello\\, with \\\\ special\\; chars and \\n newlines", - "UID:empty-uid", - "END:VEVENT" - ])) - self.assertEqual(list(sorted(str(e).splitlines())), eq) - - def test_url_input(self): - c = Calendar(cal16) - e = next(iter(c.events)) - self.assertEqual(e.url, "http://example.com/pub/calendars/jsmith/mytime.ics") - - def test_url_output(self): - URL = "http://example.com/pub/calendars/jsmith/mytime.ics" - e = Event(summary="Name", url=URL) - self.assertIn("URL:" + URL, str(e).splitlines()) - - def test_status_input(self): - c = Calendar(cal16) - e = next(iter(c.events)) - self.assertEqual(e.status, "CONFIRMED") - - def test_status_output(self): - STATUS = "CONFIRMED" - e = Event(summary="Name", status=STATUS) - self.assertIn("STATUS:" + STATUS, str(e).splitlines()) - - def test_category_input(self): - c = Calendar(cal16) - e = next(iter(c.events)) - self.assertIn("Simple Category", e.categories) - self.assertIn("My \"Quoted\" Category", e.categories) - self.assertIn("Category, with comma", e.categories) - - def test_category_output(self): - cat = "Simple category" - e = Event(summary="Name", categories={cat}) - self.assertIn("CATEGORIES:" + cat, str(e).splitlines()) - - def test_all_day_with_end(self): - c = Calendar(cal20) - e = next(iter(c.events)) - self.assertTrue(e.all_day) - - def test_not_all_day(self): - c = Calendar(cal16) - e = next(iter(c.events)) - self.assertFalse(e.all_day) - - def test_all_day_duration(self): - c = Calendar(cal20) - e = next(iter(c.events)) - self.assertTrue(e.all_day) - self.assertEqual(e.duration, td(days=2)) - - def test_make_all_day_idempotence(self): - c = Calendar(cal18) - e = next(iter(c.events)) - self.assertFalse(e.all_day) - e2 = e.clone() - e2.make_all_day() - self.assertTrue(e2.all_day) - self.assertNotEqual(e.begin, e2.begin) - self.assertNotEqual(e.end, e2.end) - e3 = e2.clone() - e3.make_all_day() - self.assertEqual(e2.begin, e3.begin) - self.assertEqual(e2.end, e3.end) - - def test_all_day_outputs_dtstart_value_date(self): - """All day events should output DTSTART using VALUE=DATE without - time and timezone in order to assume the user's current timezone - - refs http://www.kanzaki.com/docs/ical/dtstart.html - http://www.kanzaki.com/docs/ical/date.html - """ - e = Event(begin=dt(2015, 12, 21)) - e.make_all_day() - # no time or tz specifier - self.assertIn('DTSTART;VALUE=DATE:20151221', str(e).splitlines()) - - def test_transparent_input(self): - c = Calendar(cal19) - e1 = list(c.events)[0] - self.assertEqual(e1.transparent, False) - - c2 = Calendar(cal19bis) - e2 = list(c2.events)[0] - self.assertEqual(e2.transparent, True) - - def test_default_transparent_input(self): - c = Calendar(cal18) - e = next(iter(c.events)) - self.assertEqual(e.transparent, None) - - def test_default_transparent_output(self): - e = Event(summary="Name") - self.assertNotIn("TRANSP:OPAQUE", str(e).splitlines()) - - def test_transparent_output(self): - e = Event(summary="Name", transparent=True) - self.assertIn("TRANSP:TRANSPARENT", str(e).splitlines()) - - e = Event(summary="Name", transparent=False) - self.assertIn("TRANSP:OPAQUE", str(e).splitlines()) - - def test_includes_disjoined(self): - # disjoined events - event_a = Event(summary='Test #1', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=20)) - event_b = Event(summary='Test #2', begin=dt(2016, 6, 10, 20, 50), duration=td(minutes=20)) - assert not event_a.includes(event_b) - assert not event_b.includes(event_a) - - def test_includes_intersected(self): - # intersected events - event_a = Event(summary='Test #1', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) - event_b = Event(summary='Test #2', begin=dt(2016, 6, 10, 20, 30), duration=td(minutes=30)) - assert not event_a.includes(event_b) - assert not event_b.includes(event_a) - - event_a = Event(summary='Test #1', begin=dt(2016, 6, 10, 20, 30), duration=td(minutes=30)) - event_b = Event(summary='Test #2', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) - assert not event_a.includes(event_b) - assert not event_b.includes(event_a) - - def test_includes_included(self): - # included events - event_a = Event(summary='Test #1', begin=dt(2016, 6, 10, 20, 00), duration=td(minutes=60)) - event_b = Event(summary='Test #2', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) - assert event_a.includes(event_b) - assert not event_b.includes(event_a) - - event_a = Event(summary='Test #1', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) - event_b = Event(summary='Test #2', begin=dt(2016, 6, 10, 20, 00), duration=td(minutes=60)) - assert not event_a.includes(event_b) - assert event_b.includes(event_a) - - def test_intersects_disjoined(self): - # disjoined events - event_a = Event(summary='Test #1', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=20)) - event_b = Event(summary='Test #2', begin=dt(2016, 6, 10, 20, 50), duration=td(minutes=20)) - assert not event_a.intersects(event_b) - assert not event_b.intersects(event_a) - - def test_intersects_intersected(self): - # intersected events - event_a = Event(summary='Test #1', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) - event_b = Event(summary='Test #2', begin=dt(2016, 6, 10, 20, 30), duration=td(minutes=30)) - assert event_a.intersects(event_b) - assert event_b.intersects(event_a) - - event_a = Event(summary='Test #1', begin=dt(2016, 6, 10, 20, 30), duration=td(minutes=30)) - event_b = Event(summary='Test #2', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) - assert event_a.intersects(event_b) - assert event_b.intersects(event_a) - - def test_intersects_included(self): - # included events - event_a = Event(summary='Test #1', begin=dt(2016, 6, 10, 20, 00), duration=td(minutes=60)) - event_b = Event(summary='Test #2', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) - assert event_a.intersects(event_b) - assert event_b.intersects(event_a) - - event_a = Event(summary='Test #1', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) - event_b = Event(summary='Test #2', begin=dt(2016, 6, 10, 20, 00), duration=td(minutes=60)) - assert event_a.intersects(event_b) - assert event_b.intersects(event_a) - - # def test_join_disjoined(self): - # # disjoined events - # event_a = Event(name='Test #1', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=20)) - # event_b = Event(name='Test #2', begin=dt(2016, 6, 10, 20, 50), duration=td(minutes=20)) - # with pytest.raises(ValueError): - # event_a.join(event_b) - # with pytest.raises(ValueError): - # event_b.join(event_a) - # - # def test_join_intersected(self): - # # intersected events - # event_a = Event(name='Test #1', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) - # event_b = Event(name='Test #2', begin=dt(2016, 6, 10, 20, 30), duration=td(minutes=30)) - # assert event_a.join(event_b).time_equals(Event(name=None, begin=event_a.begin, end=event_b.end)) - # assert event_b.join(event_a).time_equals(Event(name=None, begin=event_a.begin, end=event_b.end)) - # - # event_a = Event(name='Test #1', begin=dt(2016, 6, 10, 20, 30), duration=td(minutes=30)) - # event_b = Event(name='Test #2', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) - # assert event_a.join(event_b).time_equals(Event(name=None, begin=event_b.begin, end=event_a.end)) - # assert event_b.join(event_a).time_equals(Event(name=None, begin=event_b.begin, end=event_a.end)) - # - # def test_join_included(self): - # # included events - # event_a = Event(name='Test #1', begin=dt(2016, 6, 10, 20, 00), duration=td(minutes=60)) - # event_b = Event(name='Test #2', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) - # assert event_a.join(event_b).time_equals(Event(name=None, begin=event_a.begin, end=event_a.end)) - # assert event_b.join(event_a).time_equals(Event(name=None, begin=event_a.begin, end=event_a.end)) - # - # event_a = Event(name='Test #1', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) - # event_b = Event(name='Test #2', begin=dt(2016, 6, 10, 20, 00), duration=td(minutes=60)) - # assert event_a.join(event_b).time_equals(Event(name=None, begin=event_b.begin, end=event_b.end)) - # assert event_b.join(event_a).time_equals(Event(name=None, begin=event_b.begin, end=event_b.end)) - # - # event = Event(uid='0', name='Test #1', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) - # event.join(event) - # assert event == Event(uid='0', name='Test #1', begin=dt(2016, 6, 10, 20, 10), duration=td(minutes=30)) - - def test_issue_92(self): - c = Calendar(cal32) - e = list(c.events)[0] - - assert e.begin == dt(2016, 10, 4) - assert e.end == dt(2016, 10, 5) - - def test_classification_input(self): - c = Calendar(cal12) - e = next(iter(c.events)) - self.assertEqual(None, e.classification) - - c = Calendar(cal33_1) - e = next(iter(c.events)) - self.assertEqual('PUBLIC', e.classification) - - c = Calendar(cal33_2) - e = next(iter(c.events)) - self.assertEqual('PRIVATE', e.classification) - - c = Calendar(cal33_3) - e = next(iter(c.events)) - self.assertEqual('CONFIDENTIAL', e.classification) - - c = Calendar(cal33_4) - e = next(iter(c.events)) - self.assertEqual('iana-token', e.classification) - - c = Calendar(cal33_5) - e = next(iter(c.events)) - self.assertEqual('x-name', e.classification) - - def test_classification_output(self): - e = Event(summary="Name") - self.assertNotIn("CLASS:PUBLIC", str(e).splitlines()) - - e = Event(summary="Name", classification='PUBLIC') - self.assertIn("CLASS:PUBLIC", str(e).splitlines()) - - e = Event(summary="Name", classification='PRIVATE') - self.assertIn("CLASS:PRIVATE", str(e).splitlines()) - - e = Event(summary="Name", classification='CONFIDENTIAL') - self.assertIn("CLASS:CONFIDENTIAL", str(e).splitlines()) - - e = Event(summary="Name", classification='iana-token') - self.assertIn("CLASS:iana-token", str(e).splitlines()) - - e = Event(summary="Name", classification='x-name') - self.assertIn("CLASS:x-name", str(e).splitlines()) - - def test_classification_bool(self): - with pytest.raises(TypeError): - Event(summary="Name", classification=True) - - def test_last_modified(self): - c = Calendar(cal18) - e = list(c.events)[0] - self.assertEqual(dt(2015, 11, 13, 00, 48, 9, tzinfo=tzutc), e.last_modified) - - def equality(self): - ev1 = Event(summary="my name", begin=dt(2018, 6, 29, 5), end=dt(2018, 6, 29, 7)) - ev2 = ev1.clone() - - assert ev1 == ev2 - - ev2.uid = "something else" - assert ev1 == ev2 - - ev2.name = "other name" - assert ev1 != ev2 - - def test_attendee_parse(self): - with open( - os.path.join(os.path.dirname(__file__), "fixtures/groupscheduled.ics") - ) as f: - c = Calendar(f.read()) - e = list(c.events)[0] - assert len(e.attendees) == 1 diff --git a/tests/fixture.py b/tests/fixture.py deleted file mode 100644 index ee30f5e2..00000000 --- a/tests/fixture.py +++ /dev/null @@ -1,766 +0,0 @@ -from __future__ import unicode_literals - -cal1 = """ -BEGIN:VCALENDAR -METHOD:PUBLISH -VERSION:2.0 -X-WR-CALNAME:plop -PRODID:-//Apple Inc.//Mac OS X 10.9//EN -X-APPLE-CALENDAR-COLOR:#882F00 -X-WR-TIMEZONE:Europe/Brussels -CALSCALE:GREGORIAN -BEGIN:VTIMEZONE -TZID:Europe/Brussels -BEGIN:DAYLIGHT -TZOFFSETFROM:+0100 -RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU -DTSTART:19810329T020000 -TZNAME:UTC+2 -TZOFFSETTO:+0200 -END:DAYLIGHT -BEGIN:STANDARD -TZOFFSETFROM:+0200 -RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU -DTSTART:19961027T030000 -TZNAME:UTC+1 -TZOFFSETTO:+0100 -END:STANDARD -END:VTIMEZONE -BEGIN:VEVENT -CREATED:20131024T204716Z -UID:ABBF2903-092F-4202-98B6-F757437A5B28 -DTEND;TZID=Europe/Brussels:20131029T113000 -TRANSP:OPAQUE -SUMMARY:dfqsdfjqkshflqsjdfhqs fqsfhlqs dfkqsldfkqsdfqsfqsfqsfs -DTSTART;TZID=Europe/Brussels:20131029T103000 -DTSTAMP:20131024T204741Z -SEQUENCE:3 -DESCRIPTION:Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed - vitae facilisis enim. Morbi blandit et lectus venenatis tristique. Donec - sit amet egestas lacus. Donec ullamcorper, mi vitae congue dictum, quam - dolor luctus augue, id cursus purus justo vel lorem. Ut feugiat enim ips - um, quis porta nibh ultricies congue. Pellentesque nisl mi, molestie id - sem vel, vehicula nullam. -END:VEVENT -BEGIN:VTODO -DTSTAMP:20180218T154700Z -UID:Uid -DESCRIPTION:Lorem ipsum dolor sit amet. -PERCENT-COMPLETE:0 -PRIORITY:0 -SUMMARY:Name -END:VTODO -END:VCALENDAR -""" - -cal2 = """ -BEGIN:VCALENDAR -BEGIN:VEVENT -DTEND;TZID=Europe/Berlin:20120608T212500 -SUMMARY:Name -DTSTART;TZID=Europe/Berlin:20120608T202500 -LOCATION:MUC -SEQUENCE:0 -BEGIN:VALARM -TRIGGER:PT1H -DESCRIPTION:Event reminder -ACTION:DISPLAY -END:VALARM -END:VEVENT -END:VCALENDAR -""" - - -cal3 = """ -BEGIN:VCALENDAR -END:VCALENDAR -""" - -cal4 = """BEGIN:VCALENDAR""" - -cal5 = """ -BEGIN:VCALENDAR -VERSION:2.0 -END:VCALENDAR -""" - -cal6 = """ -DESCRIPTION:a - b -""" - -cal7 = """ -BEGIN:VCALENDAR - -END:VCALENDAR -""" - -cal8 = """ -BEGIN:VCALENDAR -\t -END:VCALENDAR -""" - -cal9 = """ - -BEGIN:VCALENDAR -END:VCALENDAR -""" - -cal10 = u""" -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN -BEGIN:VEVENT -DTEND;TZID=Europe/Berlin:20120608T212500 -DTSTAMP:20131024T204741Z -SUMMARY:Name -DTSTART;TZID=Europe/Berlin:20120608T202500 -LOCATION:MUC -SEQUENCE:0 -BEGIN:VALARM -TRIGGER:PT1H -DESCRIPTION:Event reminder -ACTION:DISPLAY -END:VALARM -END:VEVENT -END:VCALENDAR -""" - -cal11 = u""" -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN -END:VCAL -""" - -cal12 = """ -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.8//EN -BEGIN:VEVENT -SUMMARY:Name -DTSTART;TZID=Europe/Berlin:20120608T202500 -DURATION:P1DT1H -LOCATION:MUC -GEO:40.779897;-73.968565 -SEQUENCE:0 -BEGIN:VALARM -TRIGGER:PT1H -DESCRIPTION:Event reminder -ACTION:DISPLAY -END:VALARM -END:VEVENT -END:VCALENDAR -""" - -cal13 = """ -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN -BEGIN:VEVENT -SUMMARY:Name -DTSTART;TZID=Europe/Berlin:20120608T202500 -DTEND;TZID=Europe/Berlin:20120608T212500 -DURATION:P1DT1H -LOCATION:MUC -SEQUENCE:0 -BEGIN:VALARM -TRIGGER:PT1H -DESCRIPTION:Event reminder -ACTION:DISPLAY -END:VALARM -END:VEVENT -END:VCALENDAR -""" - -cal14 = u""" -BEGIN:VCALENDAR -VERSION:2.0;42 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN -END:VCALENDAR -""" - -cal15 = u""" -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN - -BEGIN:VEVENT -SUMMARY:Hello, \\n World\\; This is a backslash : \\\\ and another new \\N line -DTSTART;TZID=Europe/Berlin:20120608T202500 -DTEND;TZID=Europe/Berlin:20120608T212500 -LOCATION:MUC -END:VEVENT - -END:VCALENDAR -""" - -# Event with URL and STATUS -cal16 = u""" -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN - -BEGIN:VEVENT -SUMMARY:Hello, \\n World\\; This is a backslash : \\\\ and another new \\N line -DTSTART;TZID=Europe/Berlin:20120608T202500 -DTEND;TZID=Europe/Berlin:20120608T212500 -LOCATION:MUC -URL:http://example.com/pub/calendars/jsmith/mytime.ics -STATUS:CONFIRMED -CATEGORIES:Simple Category,My "Quoted" Category,Category\\, with comma -END:VEVENT - -END:VCALENDAR -""" - -cal17 = u""" -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN - -BEGIN:VEVENT -SUMMARY:Some special \\; chars -DTSTART;TZID=Europe/Berlin:20130608T202501 -DTEND;TZID=Europe/Berlin:20130608T212501 -LOCATION:In\\, every text field -DESCRIPTION:Yes\\, all of them\\; -END:VEVENT -END:VCALENDAR -""" - - -# long event which is not all_day -cal18 = u""" -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:ownCloud Calendar 0.7.3 -X-WR-CALNAME:test -BEGIN:VEVENT -UID:3912dcd3d4 -DTSTAMP:20151113T004809Z -CREATED:20151113T004809Z -LAST-MODIFIED:20151113T004809Z -SUMMARY:long event -DTSTART;TZID=Europe/Berlin:20151113T140000 -DTEND;TZID=Europe/Berlin:20151124T080000 -LOCATION: -DESCRIPTION: -CATEGORIES: -END:VEVENT -END:VCALENDAR -""" - -# Event with TRANSP -cal19 = u""" -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN - -BEGIN:VEVENT -SUMMARY:E1 -TRANSP:OPAQUE -END:VEVENT - -END:VCALENDAR -""" - - -# Event with TRANSP -cal19bis = u""" -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN - -BEGIN:VEVENT -SUMMARY:E2 -TRANSP:TRANSPARENT -END:VEVENT -END:VCALENDAR -""" - -# 2 days all-day event -cal20 = u""" -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:manually crafted from an ownCloud 8.0 ics -BEGIN:VEVENT -SUMMARY:2 days party -DTSTART;VALUE=DATE:20151114 -DTEND;VALUE=DATE:20151116 -END:VEVENT -END:VCALENDAR -""" - -# Event with Display alarm without repeats -cal21 = u""" -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN - -BEGIN:VEVENT -SUMMARY:Some special \\; chars -DTSTART;TZID=Europe/Berlin:20130608T202501 -DTEND;TZID=Europe/Berlin:20130608T212501 -LOCATION:In\\, every text field -DESCRIPTION:Yes\\, all of them\\; -BEGIN:VALARM -TRIGGER:PT1H -DESCRIPTION:Event reminder -ACTION:DISPLAY -END:VALARM -END:VEVENT -END:VCALENDAR -""" - -# Event with Display alarm WITH repeats -cal22 = u""" -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN - -BEGIN:VEVENT -SUMMARY:Some special \\; chars -DTSTART;TZID=Europe/Berlin:20130608T202501 -DTEND;TZID=Europe/Berlin:20130608T212501 -LOCATION:In\\, every text field -DESCRIPTION:Yes\\, all of them\\; -BEGIN:VALARM -TRIGGER:PT1H -REPEAT:2 -DURATION:PT10M -DESCRIPTION:Event reminder -ACTION:DISPLAY -END:VALARM -END:VEVENT -END:VCALENDAR -""" - -# Event with Display alarm without repeats -cal23 = u""" -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN - -BEGIN:VEVENT -SUMMARY:Some special \\; chars -DTSTART;TZID=Europe/Berlin:20130608T202501 -DTEND;TZID=Europe/Berlin:20130608T212501 -LOCATION:In\\, every text field -DESCRIPTION:Yes\\, all of them\\; -BEGIN:VALARM -TRIGGER;VALUE=DATE-TIME:20160101T000000Z -DESCRIPTION:Event reminder -ACTION:DISPLAY -END:VALARM -END:VEVENT -END:VCALENDAR -""" - -# Event with AUDIO alarm without attach -cal24 = u""" -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN - -BEGIN:VEVENT -SUMMARY:Some special \\; chars -DTSTART;TZID=Europe/Berlin:20130608T202501 -DTEND;TZID=Europe/Berlin:20130608T212501 -LOCATION:In\\, every text field -DESCRIPTION:Yes\\, all of them\\; -BEGIN:VALARM -TRIGGER;VALUE=DATE-TIME:20160101T000000Z -ACTION:AUDIO -END:VALARM -END:VEVENT -END:VCALENDAR -""" - -# Event with AUDIO alarm WITH attach -cal25 = u""" -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN - -BEGIN:VEVENT -SUMMARY:Some special \\; chars -DTSTART;TZID=Europe/Berlin:20130608T202501 -DTEND;TZID=Europe/Berlin:20130608T212501 -LOCATION:In\\, every text field -DESCRIPTION:Yes\\, all of them\\; -BEGIN:VALARM -TRIGGER;VALUE=DATE-TIME:20160101T000000Z -ACTION:AUDIO -ATTACH;FMTTYPE=audio/basic:ftp://example.com/pub/sounds/bell-01.aud -END:VALARM -END:VEVENT -END:VCALENDAR -""" - -# Event with a tabbed line fold -cal26 = u""" -BEGIN:VCALENDAR -BEGIN:VEVENT -DTEND;TZID=Europe/Berlin:20120608T212500 -SUMMARY:Name -DTSTART;TZID=Europe/Berlin:20120608T202500 -LOCATION:MUC -SEQUENCE:0 -UID:040000008200E00074C5B7101A82E0080000000050B9861DFE30D101000000000000000 - 010000000DC18788D5CDAF947A99D8A91D04C601C -BEGIN:VALARM -TRIGGER:PT1H -DESCRIPTION:Event reminder -ACTION:DISPLAY -END:VALARM -END:VEVENT -END:VCALENDAR -""" - -# All VTODO attributes beside duration. -cal27 = """ -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN -BEGIN:VTODO -DTSTAMP:20180218T154700Z -UID:Uid -COMPLETED:20180418T150000Z -CREATED:20180218T154800Z -DESCRIPTION:Lorem ipsum dolor sit amet. -DTSTART:20180218T164800Z -LOCATION:Earth -PERCENT-COMPLETE:0 -PRIORITY:0 -SUMMARY:Name -URL:https://www.example.com/cal.php/todo.ics -DURATION:PT10M -SEQUENCE:0 -BEGIN:VALARM -TRIGGER:PT1H -DESCRIPTION:Event reminder -ACTION:DISPLAY -END:VALARM -END:VTODO -END:VCALENDAR -""" - -# Test due. -cal28 = """ -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN -BEGIN:VTODO -DTSTAMP:20180218T154741Z -UID:Uid -DUE:20180218T164800Z -END:VTODO -END:VCALENDAR -""" - -# Test error due and duration. -cal29 = """ -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN -BEGIN:VTODO -DTSTAMP:20180218T154741Z -UID:Uid -DURATION:PT10M -DUE:20180218T164800Z -END:VTODO -END:VCALENDAR -""" - -cal30 = """ -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN -BEGIN:VTODO -DTSTAMP:20180218T154741Z -UID:Uid -DUE:20180218T164800Z -DURATION:PT10M -END:VTODO -END:VCALENDAR -""" - -cal31 = """ -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN -BEGIN:VTODO -DTSTAMP:20180218T154741Z -UID:Uid -SUMMARY:Hello, \\n World\\; This is a backslash : \\\\ and another new \\N line -LOCATION:In\\, every text field -DESCRIPTION:Yes\\, all of them\\; -END:VTODO -END:VCALENDAR -""" - -cal32 = """ -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:- - -BEGIN:VEVENT -DTSTART;VALUE=DATE:20161004 -DTEND;VALUE=DATE:20161005 -SUMMARY:An all day event: October 4 2016. -END:VEVENT - -END:VCALENDAR -""" - -clas33 = """ -BEGIN:VTIMEZONE -TZID:Australia/Sydney -TZURL:http://tzurl.org/zoneinfo/Australia/Sydney -SEQUENCE:498 -SEQUENCE:498 -BEGIN:STANDARD -TZOFFSETFROM:+1100 -TZOFFSETTO:+1000 -TZNAME:EST -DTSTART:20080406T030000 -RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU -END:STANDARD -""" - -cal34 = """ -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN - -BEGIN:VEVENT -SUMMARY:a -END:VEVENT - -END:VCALENDAR - -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN - -BEGIN:VEVENT -SUMMARY:b -END:VEVENT - -END:VCALENDAR -""" - -unfolded_cal2 = [ - 'BEGIN:VCALENDAR', - 'BEGIN:VEVENT', - 'DTEND;TZID=Europe/Berlin:20120608T212500', - 'SUMMARY:Name', - 'DTSTART;TZID=Europe/Berlin:20120608T202500', - 'LOCATION:MUC', - 'SEQUENCE:0', - 'BEGIN:VALARM', - 'TRIGGER:PT1H', - 'DESCRIPTION:Event reminder', - 'ACTION:DISPLAY', - 'END:VALARM', - 'END:VEVENT', - 'END:VCALENDAR', -] - -unfolded_cal1 = [ - 'BEGIN:VCALENDAR', - 'METHOD:PUBLISH', - 'VERSION:2.0', - 'X-WR-CALNAME:plop', - 'PRODID:-//Apple Inc.//Mac OS X 10.9//EN', - 'X-APPLE-CALENDAR-COLOR:#882F00', - 'X-WR-TIMEZONE:Europe/Brussels', - 'CALSCALE:GREGORIAN', - 'BEGIN:VTIMEZONE', - 'TZID:Europe/Brussels', - 'BEGIN:DAYLIGHT', - 'TZOFFSETFROM:+0100', - 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU', - 'DTSTART:19810329T020000', - 'TZNAME:UTC+2', - 'TZOFFSETTO:+0200', - 'END:DAYLIGHT', - 'BEGIN:STANDARD', - 'TZOFFSETFROM:+0200', - 'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU', - 'DTSTART:19961027T030000', - 'TZNAME:UTC+1', - 'TZOFFSETTO:+0100', - 'END:STANDARD', - 'END:VTIMEZONE', - 'BEGIN:VEVENT', - 'CREATED:20131024T204716Z', - 'UID:ABBF2903-092F-4202-98B6-F757437A5B28', - 'DTEND;TZID=Europe/Brussels:20131029T113000', - 'TRANSP:OPAQUE', - 'SUMMARY:dfqsdfjqkshflqsjdfhqs fqsfhlqs dfkqsldfkqsdfqsfqsfqsfs', - 'DTSTART;TZID=Europe/Brussels:20131029T103000', - 'DTSTAMP:20131024T204741Z', - 'SEQUENCE:3', - 'DESCRIPTION:Lorem ipsum dolor sit amet, consectetur adipiscing elit. \ -Sedvitae facilisis enim. Morbi blandit et lectus venenatis tristique. \ -Donecsit amet egestas lacus. Donec ullamcorper, mi vitae congue dictum, \ -quamdolor luctus augue, id cursus purus justo vel lorem. \ -Ut feugiat enim ipsum, quis porta nibh ultricies congue. \ -Pellentesque nisl mi, molestie idsem vel, vehicula nullam.', - 'END:VEVENT', - 'BEGIN:VTODO', - 'DTSTAMP:20180218T154700Z', - 'UID:Uid', - 'DESCRIPTION:Lorem ipsum dolor sit amet.', - 'PERCENT-COMPLETE:0', - 'PRIORITY:0', - 'SUMMARY:Name', - 'END:VTODO', - 'END:VCALENDAR', -] - -unfolded_cal6 = ['DESCRIPTION:ab'] - -unfolded_cal21 = [ - 'BEGIN:VCALENDAR', - 'BEGIN:VEVENT', - 'DTEND;TZID=Europe/Berlin:20120608T212500', - 'SUMMARY:Name', - 'DTSTART;TZID=Europe/Berlin:20120608T202500', - 'LOCATION:MUC', - 'SEQUENCE:0', - 'BEGIN:VALARM', - 'TRIGGER:PT1H', - 'REPEAT:2', - 'DURATION:PT10M', - 'DESCRIPTION:Event reminder', - 'ACTION:DISPLAY', - 'END:VALARM', - 'END:VEVENT', - 'END:VCALENDAR', -] - -unfolded_cal26 = [ - 'BEGIN:VCALENDAR', - 'BEGIN:VEVENT', - 'DTEND;TZID=Europe/Berlin:20120608T212500', - 'SUMMARY:Name', - 'DTSTART;TZID=Europe/Berlin:20120608T202500', - 'LOCATION:MUC', - 'SEQUENCE:0', - 'UID:040000008200E00074C5B7101A82E0080000000050B9861DFE30D101000000000000000010000000DC18788D5CDAF947A99D8A91D04C601C', - 'BEGIN:VALARM', - 'TRIGGER:PT1H', - 'DESCRIPTION:Event reminder', - 'ACTION:DISPLAY', - 'END:VALARM', - 'END:VEVENT', - 'END:VCALENDAR', -] - -cal33_1 = """ -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN -BEGIN:VEVENT -DTEND;TZID=Europe/Berlin:20120608T212500 -SUMMARY:Name -DTSTART;TZID=Europe/Berlin:20120608T202500 -LOCATION:MUC -SEQUENCE:0 -CLASS:PUBLIC -END:VEVENT -END:VCALENDAR -""" - -cal33_2 = """ -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN -BEGIN:VEVENT -DTEND;TZID=Europe/Berlin:20120608T212500 -SUMMARY:Name -DTSTART;TZID=Europe/Berlin:20120608T202500 -LOCATION:MUC -SEQUENCE:0 -CLASS:PRIVATE -END:VEVENT -END:VCALENDAR -""" - -cal33_3 = """ -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN -BEGIN:VEVENT -DTEND;TZID=Europe/Berlin:20120608T212500 -SUMMARY:Name -DTSTART;TZID=Europe/Berlin:20120608T202500 -LOCATION:MUC -SEQUENCE:0 -CLASS:CONFIDENTIAL -END:VEVENT -END:VCALENDAR -""" - -cal33_4 = """ -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN -BEGIN:VEVENT -DTEND;TZID=Europe/Berlin:20120608T212500 -SUMMARY:Name -DTSTART;TZID=Europe/Berlin:20120608T202500 -LOCATION:MUC -SEQUENCE:0 -CLASS:iana-token -END:VEVENT -END:VCALENDAR -""" - -cal33_5 = """ -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN -BEGIN:VEVENT -DTEND;TZID=Europe/Berlin:20120608T212500 -SUMMARY:Name -DTSTART;TZID=Europe/Berlin:20120608T202500 -LOCATION:MUC -SEQUENCE:0 -CLASS:x-name -END:VEVENT -END:VCALENDAR -""" - - -cal35 = u""" -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN - -BEGIN:VEVENT -BEGIN:VALARM -TRIGGER;VALUE=DATE-TIME:20160101T000000Z -ACTION:NONE -END:VALARM -END:VEVENT -END:VCALENDAR -""" - -cal36 = u""" -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Inc.//Mac OS X 10.9//EN - -BEGIN:VEVENT -BEGIN:VALARM -TRIGGER;VALUE=DATE-TIME:20160101T000000Z -ACTION:YOLO -END:VALARM -END:VEVENT -END:VCALENDAR -""" diff --git a/tests/fixtures/README b/tests/fixtures/README deleted file mode 100644 index 5256c86b..00000000 --- a/tests/fixtures/README +++ /dev/null @@ -1,35 +0,0 @@ -encoding.ics, multiple.ics, small.ics, timezoned.ics, case_meetup.ics, groupscheduled.ics, recurrence.ics and time.ics are Copyright (c) 2012-2013, Plone Foundation under the BSD License and modified to suit ics.py needs. Source : https://pypi.python.org/pypi/icalendar - -utf-8-emoji.ics contains data with the following Copyright: - Markus Kuhn [ˈmaʳkʊs kuːn] — 2002-07-25 CC BY -and - © 2019 Unicode®, Inc. - Unicode and the Unicode Logo are registered trademarks of Unicode, Inc. in the U.S. and other countries. - For terms of use, see http://www.unicode.org/terms_of_use.html - -Romeo-and-Juliet.txt and Romeo-and-Juliet.ics contains data exported from Wikisource on 02/23/20: - This e-book comes from the online library Wikisource[1]. This multilingual digital library, built by volunteers, is committed to developing a free accessible collection of publications of every kind: novels, poems, magazines, letters... - We distribute our books for free, starting from works not copyrighted or published under a free license. You are free to use our e-books for any purpose (including commercial exploitation), under the terms of the Creative Commons Attribution-ShareAlike 3.0 Unported[2] license or, at your choice, those of the GNU FDL[3]. - Wikisource is constantly looking for new members. During the realization of this book, it's possible that we made some errors. You can report them at this page[4]. - - The following users contributed to this book: - Angelprincess72 - Djr13 - ThomasBot - BirgitteSB - Mpaa - Beleg Tâl - Einstein95 - Kathleen.wright5 - EncycloPetey - Dariyman - - * * * - - ↑ http://wikisource.org - - ↑ http://www.creativecommons.org/licenses/by-sa/3.0 - - ↑ http://www.gnu.org/copyleft/fdl.html - - ↑ http://wikisource.org/wiki/Wikisource:Scriptorium diff --git a/tests/fixtures/Romeo-and-Juliet.ics b/tests/fixtures/Romeo-and-Juliet.ics deleted file mode 100644 index 63da8c57..00000000 --- a/tests/fixtures/Romeo-and-Juliet.ics +++ /dev/null @@ -1,556 +0,0 @@ -BEGIN:VCALENDAR -PRODID:-//Nextcloud calendar v1.7.2 -VERSION:2.0 -CALSCALE:GREGORIAN -BEGIN:VEVENT -CREATED:20200223T183124 -DTSTAMP:20200223T183124 -LAST-MODIFIED:20200223T183124 -UID:JCGUPMSIMHOT80Q0CN3NV -SUMMARY:Theatre -CLASS:PUBLIC -DESCRIPTION:Romeo and Juliet (The Illustrated Shakespeare)\n\n\n Willia - m Shakespeare\n\n\n\n\n\n1847\n\n\n\n\n\nExported from Wikisource on 02/23/ - 20\n\n\n\n\n\nThis work may need to be standardized using Wikisource's styl - e guidelines.\n\nIf you'd like to help\, please review the help pages.\n\n\ - n\n\n\nPROLOGUE\n\n\n​ PROLOGUE\n\n\n\nCHORUS.\n\n\n\nTwo households\, both - alike in dignity\,\n\nIn fair Verona\, where we lay our scene\,\n\nFrom an - cient grudge break to new mutiny\,\n\nWhere civil blood makes civil hands u - nclean.\n\nFrom forth the fatal loins of these two foes\n\nA pair of star-c - ross'd lovers take their life\;\n\nWhose misadventur'd piteous overthrows\n - \nDo\, with their death\, bury their parents' strife.\n\nThe fearful passag - e of their death-mark'd love\,\n\nAnd the continuance of their parents' rag - e\,\n\nWhich\, but their children's end\, nought could remove\,\n\nIs now t - he two hours' traffic of our stage\;\n\nThe which if you with patient ears - attend\,\n\nWhat here shall miss\, our toil shall strive to mend.\n\n\n\n\n - \nAn image should appear at this position in the text.\n\nIf you are able t - o provide it\, see Wikisource:Image guidelines and Help:Adding images for g - uidance.\n\n\n\n\n\nACT I\n\n\n​\n\n\n\nScene I.—A Public Place.\n\nEnter S - ampson and Gregory\, armed with Swords and Bucklers.\n\n\n\nSam. Gregory\, - on my word\, we'll not carry coals.\n\nGre. No\, for then we should be coll - iers.\n\nSam. I mean\, an we be in choler\, we'll draw.\n\nGre. Ay\, while - you live\, draw your neck out of the collar.\n\nSam. I strike quickly\, bei - ng moved.\n\nGre. But thou art not quickly moved to strike.\n\nSam. A dog o - f the house of Montague moves me.\n\nGre. To move is to stir\, and to be va - liant is to stand: therefore\, if thou art moved\, thou run'st away.\n\nSam - . A dog of that house shall move me to stand. I will take the wall of any m - an or maid of Montague's.\n\nGre. That shows thee a weak slave\; for the we - akest goes to the wall.\n\nSam. 'Tis true\; and therefore women\, being the - weaker vessels\,are ever thrust to the wall:—therefore\, I will push Monta - gue's men from the wall\, and thrust his maids to the wall.\n\nGre. The qua - rrel is between our masters\, and us their men.\n\nSam. 'Tis all one\, I wi - ll show myself a tyrant: when I have fought with the men\, I will be civil - with the maids\; I will cut off their heads.\n\nGre. The heads of the maids - ?\n\nSam. Ay\, the heads of the maids\, or their maidenheads\; take it in w - hat sense thou wilt.\n\nGre. They must take it in sense\, that feel it.\n\n - Sam. Me they shall feel\, while I am able to stand\; and 'tis known\, I am - a pretty piece of flesh.\n\nGre. 'Tis well\, thou art not fish\; if thou ha - dst\, ​thou hadst been poor John. Draw thy tool\; here comes two of the hou - se of the Montagues.\n\n\n\n\n\nEnter Abraham and Balthasar.\n\n\n\nSam. My - naked weapon is out: quarrel\, I will back thee.\n\nGre. How! turn thy bac - k\, and run?\n\nSam. Fear me not.\n\nGre. No marry: I fear thee!\n\nSam. Le - t us take the law of our sides\; let them begin.\n\nGre. I will frown as I - pass by\, and let them take it as they list.\n\nSam. Nay\, as they dare. I - will bite my thumb at them\; which is a disgrace to them\, if they bear it. - \n\nAbr. Do you bite your thumb at us\, sir?\n\nSam. I do bite my thumb\, s - ir.\n\nAbr. Do you bite your thumb at us\, sir?\n\n\n\nAn image should appe - ar at this position in the text.\n\nIf you are able to provide it\, see Wik - isource:Image guidelines and Help:Adding images for guidance.\n\n\n\nSam. I - s the law of our side\, if I say—ay?\n\nGre. No.\n\nSam. No\, sir\, I do no - t bite my thumb at you\, sir\; but I bite my thumb\, sir.\n\nGre. Do you qu - arrel\, sir?\n\nAbr. Quarrel\, sir? no\, sir.\n\nSam. If you do\, sir\, I a - m for you: I serve as good a man as you.\n\nAbr. No better.\n\nSam. Well\, - sir.\n\n\n\n\n\nEnter Benvolio\, at a distance.\n\n\n\nGre. Say—better: her - e comes one of my master's kinsmen.\n\nSam. Yes\, better\, sir.\n\nAbr. You - lie.\n\nSam. Draw\, if you be men.—Gregory\, remember thy swashing blow. [ - They fight.\n\nBen. Part\, fools! put up your swords\; you know not what yo - u do. [Beats down their Swords.\n\n\n\n\n\nEnter Tybalt.\n\n\n\nTyb. What! - art thou drawn among these heartless hinds?\n\nTurn thee\, Benvolio\, look - upon thy death.\n\nBen. I do but keep the peace: put up thy sword\,\n\nOr m - anage it to part these men with me.\n\nTyb. What! drawn\, and talk of peace - ? I hate the word\,\n\nAs I hate hell\, all Montagues\, and thee.\n\n\n\nHa - ve at thee\, coward. [They fight.\n\n​ Enter several persons of both Houses - \, who join the fray\; then enter Citizens\, with clubs or partisans.\n\n\n - \n1 Cit. Clubs\, bills\, and partisans! strike! beat them down!\n\nDown wit - h the Capulets! down with the Montagues!\n\n\n\n\n\nEnter Capulet in his go - wn\; and Lady Capulet.\n\n\n\nCap. What noise is this?—Give me my long swor - d\, ho!\n\nLa. Cap. A crutch\, a crutch!—Why call you for a sword?\n\nCap. - My sword\, I say!—Old Montague is come\,\n\nAnd flourishes his blade in spi - te of me.\n\n\n\n\n\nEnter Montague and Lady Montague.\n\n\n\nMon. Thou vil - lain Capulet!—Hold me not\; let me go.\n\nLa. Mon. Thou shalt not stir one - foot to seek a foe.\n\n\n\n\n\nEnter Prince\, with his train.\n\n\n\nPrin. - Rebellious subjects\, enemies to peace\,\n\nProfaners of this neighbour-sta - ined steel—\n\nWill they not hear?—what ho! you men\, you beasts\,\n\nThat - quench the fire of your pernicious rage\n\nWith purple fountains issuing fr - om your veins\,\n\nOn pain of torture\, from those bloody hands\n\nThrow yo - ur mis–temper'd weapons to the ground\,\n\nAnd hear the sentence of your mo - ved prince.—\n\nThree civil brawls\, bred of an airy word\,\n\nBy thee\, ol - d Capulet\, and Montague\,\n\nHave thrice disturb'd the quiet of our street - s\;\n\nAnd made Verona's ancient citizens\n\nCast by their grave beseeming - ornaments\,\n\nTo wield old partisans\, in hands as old\,\n\nCanker'd with - peace\, to part your canker'd hate.\n\nIf ever you disturb our streets agai - n\,\n\nYour lives shall pay the forfeit of the peace:\n\nFor this time\, al - l the rest depart away.\n\nYou Capulet\, shall go along with me\;\n\nAnd\, - Montague\, come you this afternoon\,\n\nTo know our further pleasure in thi - s case\,\n\nTo old Free-town\, our common judgment-place.\n\n\n\nOnce more\ - , on pain of death\, all men depart. [Exeunt Prince and Attendants\; Capule - t\, Lady Capulet\, Tybalt\, Citizens\, and Servants.\n\nMon. Who set this a - ncient quarrel new abroach?\n\nSpeak\, nephew\, were you by when it began?\ - n\nBen. Here were the servants of your adversary\,\n\nAnd yours\, close fig - hting ere I did approach.\n\nI drew to part them: in the instant came\n\nTh - e fiery Tybalt\, with his sword prepar'd\;\n\nWhich\, as he breath'd defian - ce to my ears\,\n\nHe swung about his head\, and cut the winds\,\n\nWho\, n - othing hurt withal\, hiss'd him in scorn.\n\nWhile we were interchanging th - rusts and blows\,\n\nCame more and more\, and fought on part and part\,\n\n - Till the prince came\, who parted either part.\n\nLa. Mon. O! where is Rome - o?—saw you him to-day?\n\nRight glad I am he was not at this fray.\n\nBen. - Madam\, an hour before the worshipp'd sun\n\nPeer'd forth the golden window - of the east\,\n\nA troubled mind drave me to walk abroad\;\n\nWhere\, unde - rneath the grove of sycamore\n\nThat westward rooteth from the city's side\ - ,\n\nSo early walking did I see your son.\n\nTowards him I made\; but he wa - s 'ware of me\,\n\nAnd stole into the covert of the wood:\n\nI\, measuring - his affections by my own\,\n\nWhich then most sought\, where most might not - be found\,\n\nBeing one too many by my weary self\,\n\nPursu'd my humour\, - not pursuing his\,\n\nAnd gladly shunn'd who gladly fled from me.\n\nMon. - Many a morning hath he there been seen\,\n\nWith tears augmenting the fresh - morning's dew\,\n\nAdding to clouds more clouds with his deep sighs:\n\nBu - t all so soon as the all-cheering sun\n\nShould in the furthest east begin - to draw\n\nThe shady curtains from Aurora's bed\,\n\nAway from the light st - eals home my heavy son\,\n\nAnd private in his chamber pens himself\;\n\nSh - uts up his windows\, locks fair daylight out\,\n\nAnd makes himself an arti - ficial night.\n\nBlack and portentous must this humour prove\,\n\nUnless go - od counsel may the cause remove.\n\nBen. My noble uncle\, do you know the c - ause?\n\nMon. I neither know it\, nor can learn of him.\n\nBen. Have you im - portun'd him by any means?\n\nMon. Both by myself\, and many other friends: - \n\nBut he\, his own affections' counsellor\,\n\nIs to himself—I will not s - ay\, how true—\n\nBut to himself so secret and so close\,\n\nSo far from so - unding and discovery\,\n\nAs is the bud bit with an envious worm.\n\nEre he - can spread his sweet leaves to the air\,\n\nOr dedicate his beauty to the - sun.\n\nCould we but learn from whence his sorrows grow\,\n\nWe would as wi - llingly give cure\, as know.\n\n\n\n\n\nEnter Romeo\, at a distance.\n\n\n\ - nBen. See\, where he comes: so please you\, step aside\;\n\nI'll know his g - rievance\, or be much denied.\n\nMon. I would\, thou wert so happy by thy s - tay\,\n\n\n\nTo hear true shrift.—Come\, madam\, let's away. [Exeunt Montag - ue and Lady.\n\nBen. Good morrow\, cousin.\n\nRom. Is the day so young?\n\n - Ben. But new struck nine.\n\nRom. Ah me! sad hours seem long.\n\nWas that m - y father that went hence so fast?\n\nBen. It was. What sadness lengthens Ro - meo's hours?\n\nRom. Not having that\, which\, having\, makes them short.\n - \nBen. In love?\n\nRom. Out.\n\nBen. Of love?\n\nRom. Out of her favour\, w - here I am in love.\n\nBen. Alas\, that love\, so gentle in his view\,\n\nSh - ould be so tyrannous and rough in proof!\n\nRom. Alas\, that love\, whose v - iew is muffled still\,\n\nShould\, without eyes\, see pathways to his will! - \n\nWhere shall we dine?—O me!—What fray was here?\n\nYet tell me not\, for - I have heard it all.\n\nHere's much to do with hate\, but more with love:\ - n\nWhy then\, O brawling love! O loving hate!\n\nO any thing\, of nothing f - irst created!\n\nO heavy lightness! serious vanity!\n\nMis-shapen chaos of - well-seeming forms!\n\nFeather of lead\, bright smoke\, cold fire\, sick he - alth!\n\nStill-waking sleep\, that is not what it is!—\n\nThis love feel I\ - , that feel no love in this.\n\nDost thou not laugh?\n\nBen. No\, coz\; I r - ather weep.\n\nRom. Good heart\, at what?\n\n\n\n​Ben. At thy good heart's - oppression.\n\nRom. Why\, such is love's transgression.-\n\nGriefs of mine - own lie heavy in my breast\;\n\nWhich thou wilt propagate\, to have it pres - se'd\n\nWith more of thine: this love\, that thou hast shown\,\n\nDoth add - more grief to too much of mine own.\n\nLove is a smoke\, made with the fume - of sighs\;\n\nBeing purg'd\, a fire sparkling in lovers' eyes\;\n\nBeing v - ex'd\, a sea nourish'd with lover's tears:\n\nWhat is it else? a madness mo - st discreet\,\n\nA choking gall\, and a preserving sweet.\n\n\n\nFarewell\, - my coz. [Going.\n\nBen. Soft\, I will go along:\n\nAnd if you leave me so\ - , you do me wrong.\n\nRom. Tut! I have lost myself: I am not here\;\n\nThis - is not Romeo\, he's some other where.\n\nBen. Tell me in sadness\, who is - it that you love.\n\nRom. What! shall I groan\, and tell thee?\n\nBen Groan - ! why\, no\;\n\nBut sadly tell me\, who.\n\nRom. Bid a sick man in sadness - make his will\;\n\nA word ill urg'd to one that is so ill.-\n\nIn sadness\, - cousin\, I do love a woman.\n\nBen. I aim'd so near\, when I suppos'd you - lov'd.\n\nRom. A right good mark-man! And she's fair I love.\n\nBen. A righ - t fair mark\, fair coz\, is soonest hit.\n\nRom. Well\, in that hit\, you m - iss: she'll not be hit.\n\nWith Cupid's arrow. She hath Dian's wit\;\n\nAnd - in strong proof of chastity well arm'd\,\n\nFrom love's weak childish bow - she lives unharm'd\n\nShe will not stay the siege of loving terms\,\n\nNor - bide th' encounter of assailing eyes\,\n\nNor ope her lap to saint-seducing - gold:\n\nO! she is rich in beauty\; only poor\,\n\nThat when she dies with - beauty dies her store.\n\nBen. Then she hath sworn\, that she will still l - ive chaste?\n\nRom. She hath\, and in that sparing makes huge waste\;\n\nFo - r beauty\, starv'd with her severity\,\n\nCuts beauty off from all posterit - y.\n\nShe is too fair\, too wise\; wisely too fair\,\n\nTo merit bliss by m - aking me despair:\n\nShe hath forsworn to love\, and in that vow\n\nDo I li - ve dead\, that live it to tell it now.\n\nBen. Be rul'd by me\; forget to t - hink of her.\n\nRom. O! teach me how I should forget to think.\n\nBen. By g - iving liberty unto thine eyes:\n\nExamine other beauties.\n\nRom. 'Tis the - way\n\nTo call her's\, exquisite\, in question more.\n\nThese happy masks\, - that kiss fair ladies' brows\,\n\nBeing black\, put us in mind they hide t - he fair:\n\nHe\, that is stricken blind\, cannot forget\n\nThe precious tre - asure of his eyesight lost.\n\nShow me a mistress that is passing fair\,\n\ - nWhat doth her beauty serve\, but as a note\n\nWhere I may read who pass'd - that passing fair?\n\nFarewell: thou canst not teach me to forget.\n\nBen. - I'll pay that doctrine\, or else die in debt. [Exeunt.\n\n\n\n\n\nAn image - should appear at this position in the text.\n\nIf you are able to provide i - t\, see Wikisource:Image guidelines and Help:Adding images for guidance.\n\ - n\n\n(Verona.)\n\n\n\n\n\n​ Scene II.—A Street.\n\nEnter Capulet\, Paris\, - and Servant.\n\n\n\nCap. But Montague is bound as well as I\,\n\nIn penalty - alike\; and 'tis not hard\, I think\,\n\nFor men so old as we to keep the - peace.\n\nPar. Of honourable reckoning are you both\;\n\nAnd pity 'tis\, yo - u liv'd at odds so long.\n\nBut now\, my lord\, what say you to my suit?\n\ - nCap. But saying o'er what I have said before\;\n\nMy child is yet a strang - er in the world\,\n\nShe hath not seen the change of fourteen years:\n\nLet - two more summers wither in their pride\,\n\nEre we may think her ripe to b - e bride.\n\nPar. Younger than she are happy mothers made.\n\nCap. And too s - oon marr'd are those so early made.\n\nEarth hath swallowed all my hopes bu - t she\,\n\nShe is the hopeful lady of my earth:\n\nBut woo her\, gentle Par - is\, get her heart\,\n\nMy will to her consent is but a part\;\n\nAn she ag - ree\, within her scope of choice\n\nLies my consent and fair according voic - e.\n\nThis night I hold an old accustom'd feast\,\n\nWhereto I have invited - many a guest\,\n\nSuch as I love\; and you among the store\,\n\nOne more m - ost welcome\, makes my number more.\n\nAt my poor house look to behold this - night\n\nEarth-treading stars\, that make dark heaven light:\n\nSuch comfo - rt\, as do lusty young men feel\,\n\nWhen well-apparel'd April on the heel\ - n\nOf limping winter treads\, even such delight\n\nAmong fresh female buds - shall you see this night\n\nInherit at my house: hear all\, all see\,\n\nAn - d like her most\, whose merit most shall be:\n\nWhich\, on more view of man - y\, mine being one\,\n\nMay stand in number\, though in reckoning none-\n\n - Come\, go with me—Go\, sirrah\, trudge about\n\nThrough fair Verona\; find - those persons out\,\n\n\n\nWhose names are written there\, and to give them - say\, [Giving a paper.\n\nMy house and welcome on their pleasure stay. [Ex - eunt Capulet and Paris.\n\nServ. Find them out\, whose names are written he - re? It is written\, that the shoemaker should meddle with his yard\, and th - e tailor with his last\, the fisher with his pencil\, and the painter with - his nets\; but I am sent to find those persons\, whose names are here writ\ - , and can never find what names the writing person hath here writ. I must t - o the learned:—in good time.\n\n\n\n\n\nEnter Benvolio and Romeo.\n\n\n\nBe - n. Tut\, man! one fire burns out another's burning\,\n\n⁠One pain lessen'd - by another's anguish\;\n\nTurn giddy\, and be holp by backward turning\;\n\ - n⁠One desperate grief cures with another's languish:\n\nTake thou some new - infection to thy eye\,\n\nAnd the rank poison of the old will die.\n\nRom. - Your plantain leaf is excellent for that.\n\nBen. For what\, I pray thee?\n - \nRom. For your broken shin.\n\nBen. Why\, Romeo\, are thou mad?\n\nRom. No - t mad\, but bound more than a madman is:\n\nShut up in prison\, kept withou - t my food\,\n\nWhipp'd and tormented\, and—Good-den\, good fellow.\n\nServ. - God gi' good den.—I pray\, sir\, can you read?\n\nRom. Ay\, mine own fortu - ne in my misery.\n\nServ. Perhaps you have learn'd it without book\; but I - pray\, can you read anything you see?\n\nRom. Ay\, if I know the letters\, - and the language.\n\nServ. Ye say honestly. Rest you merry.\n\nRom. Stay\, - fellow\; I can read. [Reads.\n\n"Signior Martino\, and his wife\, and daugh - ters\; County Anselme\, and his beauteous sisters\; the lady widow of Vitru - vio\; Signior Placentio\, and his lovely nieces\; Mercutio\, and his brothe - r Valentine\; mine uncle Capulet\, his wife\, and daughters\; my fair niece - Rosaline\; Livia\; Signior Valentio\, and his cousin Tybalt\; Lucio\, and - the lively Helena."\n\nA fair assembly\; whither should they come?\n\nServ. - Up.\n\nRom. Whither? to supper?\n\nServ. To our house.\n\nRom. Whose house - ?\n\nServ. My master's.\n\nRom. Indeed\, I should have asked you that befor - e.\n\nServ. Now\, I'll tell you without asking. My master is the great rich - Capulet\; and if you be not of the house of Montagues\, I pray\, come and - crush a cup of wine. Rest you merry. [Exit.\n\nBen. At this same ancient fe - ast of Capulet's\n\nSups the fair Rosaline\, whom thou so lov'st\,\n\nWith - all the admired beauties of Verona:\n\nGo thither\; and\, with unattainted - eye\,\n\nCompare her face with some that I shall show\,\n\nAnd I will make - thee think thy swan a crow.\n\nRom. When the devout religion of mine eye\n\ - n⁠Maintains such falsehood\, then turns tears to fires\;\n\nAnd these\, who - \, often drown'd\, could never die\,\n\n⁠Transparent heretics\, be burnt fo - r liars.\n\nOne fairer than my love! the all-seeing sun\n\nNe'er saw her ma - tch\, since first the world begun.\n\nBen. Tut! you saw her fair\, none els - e being by\,\n\nHerself pois'd with herself in either eye\;\n\nBut in those - crystal scales\, let there be weigh'd\n\nYour lady's love against some oth - er maid\,\n\nThat I will show you shining at this feast\,\n\nAnd she shall - scant show well\, that now shows best.\n\nRom. I'll go along\, no such sigh - t to be shown\,\n\n\n\nBut to rejoice in splendour of mine own. [Exeunt.\n\ - n\n\n\n\nScene III—A Room in Capulet's House.\n\nEnter Lady Capulet and Nur - se.\n\n\n\nLa. Cap. Nurse\, where's my daughter? call her forth to me.\n\nN - urse. Now\, by my maiden-head at twelve year old\,\n\nI bade her come—What\ - , lamb! what\, lady-bird!—\n\nGod forbid!—where's this girl?—what\, Juliet! - \n\n\n\n\n\nEnter Juliet.\n\n\n\nJul. How now! who calls?\n\nNurse. Your mo - ther.\n\nJul. Madam\, I am here.\n\nWhat is your will?\n\nLa. Cap. This is - the matter.—Nurse\, give leave awhile\,\n\nWe must talk in secret.—Nurse\, - come back again:\n\nI have remember'd me\, thou shalt hear our counsel.\n\n - Thou know'st my daughter's of a pretty age.\n\nNurse. 'Faith\, I can tell h - er age unto an hour.\n\nLa. Cap. She's not fourteen.\n\nNurse. I'll lay fou - rteen on my teeth.\n\nAnd yet to my teen be it spoken I have but four\,\n\n - She is not fourteen. How long is it now\n\nTo Lammas-tide?\n\n\n\n​La. Cap. - A fortnight\, and odd days.\n\nNurse. Even or odd\, of all days in the yea - r\,\n\nCome Lammas-eve at night shall she be fourteen.\n\nSusan and she\,—G - od rest all Christian souls!—\n\nWere of an age.—Well\, Susan is with God\; - \n\nShe was too good for me. But\, as I said\,\n\nOn Lammas-eve at night sh - all she be fourteen\;\n\nThat shall she\, marry: I remember it well.\n\n'Ti - s since the earthquake now eleven years\;\n\nAnd she was wean'd\,—I never s - hall forget it\,—\n\nOf all the days of the year\, upon that day\;\n\nFor I - had then laid wormwood to my dug\,\n\nSitting in the sun under the dove-ho - use wall:\n\nMy lord and you were then at Mantua.—\n\nNay\, I do bear a bra - in:—but\, as I said\,\n\nWhen it did taste the wormwood on the nipple\n\nOf - my dug\, and felt it bitter\, pretty fool\,\n\nTo see it tetchy\, and fall - out with the dug!\n\nShake\, quoth the dove-house: 'twas no need\, I trow\ - ,\n\nTo bid me trudge.\n\nAnd since that time it is eleven years\;\n\nFor t - hen she could stand alone\; nay\, by the rood\,\n\nShe could have run and w - addled all about\,\n\nFor even the day before she broke her brow:\n\nAnd th - en my husband—God be with his soul!\n\n'A was a merry man\,—took up the chi - ld:\n\n"Yea\," quoth he\, "dost thou fall upon thy face?\n\n\n\nThou wilt f - all backward\, when thou hast more wit\;\n\nAn image should appear at this - position in the text.\n\nIf you are able to provide it\, see Wikisource:Ima - ge guidelines and Help:Adding images for guidance.\n\n\n\n\n\nWilt thou not - \, Jule?" and\, by my holy-dam\,\n\nThe pretty wretch left crying\, and sai - d—"Ay."\n\nTo see\, now\, how a jest shall come about!\n\nI warrant\, an I - should live a thousand years.\n\nI never should forget it: "Wilt thou not\, - Jule?" quoth he:\n\nAnd\, pretty fool\, it stinted\, and said—"Ay."\n\nLa. - Cap. Enough of this: I pray thee: hold they peace.\n\nNurse. Yes\, madam. - Yet I cannot choose but laugh\,\n\nTo think it should leave crying\, and sa - y—"Ay:"\n\nAnd yet\, I warrant\, it had upon its brow\n\nA bump as big as a - young cockerel's stone\,\n\nA perilous knock\; and it cried bitterly.\n\n" - Yea\," quoth my husband\, "fall'st upon thy face?\n\nThou wilt fall backwar - d\, when thou com'st to age\;\n\nWilt thou not\, Jule?" it stinted\, and sa - id—"Ay."\n\nJul. And stint thou too\, I pray thee\, nurse\, say I.\n\nNurse - . Peace\, I have done. God mark thee to his grace!\n\nThou was the pretties - t babe that e'er I nurs'd:\n\nAn I might live to see thee married once\,\n\ - nI have my wish.\n\n\n\n​La. Cap. Marry\, that marry is the very theme\n\nI - came to talk of:—tell me\, daughter Juliet\,\n\nHow stands your dispositio - n to be married?\n\nJul. It is an honour that I dream not of.\n\nNurse. An - honour! were not I thine only nurse\,\n\nI would say\, thou hadst sucked wi - sdom from thy teat.\n\nLa. Cap. Well\, think of marriage now\; younger than - you\,\n\nHere in Verona\, ladies of esteem\,\n\nAre made already mothers: - by my count\,\n\nI was your mother\, much upon these years\n\nThat you are - now a maid. Thus\, then\, in brief\;—\n\nThe valiant Paris seeks you for hi - s love.\n\nNurse. A man\, young lady! lady\, such a man\,\n\nAs all the wor - ld—Why\, he's a man of wax.\n\nLa. Cap. Verona's summer hath not such a flo - wer.\n\nNurse. Nay\, he's a flower\; in faith\, a very flower.\n\nLa. Cap. - What say you? can you love the gentleman?\n\nThis night you shall behold hi - m at out' feast:\n\nRead o'er the volume of young Paris' face\,\n\nAnd find - delight writ there with beauty's pen.\n\nExamine every married lineament\, - \n\nAnd see how one another lends content\;\n\nAnd what obscur'd in this fa - ir volume lies\,\n\nFind written in the margin of his eyes.\n\nThis preciou - s book of love\, this unbound lover\,\n\nTo beautify him\, only lacks a cov - er:\n\nThe fish lives in the sea\; and 'tis much pride\,\n\nFor fair withou - t the fair within to hide.\n\nThat book in many's eyes doth share the glory - \,\n\nThat in gold clasps locks in the golden story\;\n\nSo shall you share - all that he doth possess\,\n\nBy having him making yourself no less.\n\nNu - rse. No less? nay\, bigger: women grow by men.\n\nLa. Cap. Speak briefly\, - can you like of Paris' love?\n\nJul. I'll look to like\, if looking liking - move\;\n\nBut no more deep will I endart mine eye\,\n\nThan your consent gi - ves strength to make it fly.\n\nEnter a Servant.\n\n\n\nServ. Madam\, the g - uests are come\, supper served up\, you called\, my young lady asked for\, - the nurse cursed in the pantry\, and every thing in extremity. I must hence - to wait\; I beseech you\, follow straight.\n\nLa. Cap. We follow thee. Jul - iet\, the county stays.\n\nNurse. Go\, girl\, seek happy nights to happy da - ys. [Exeunt.\n\n\n\n\n\nScene IV.—A Street.\n\nEnter Romeo\, Mercutio\, Ben - volio\, with five or six Maskers\, Torch-bearers\, and others.\n\n\n\nRom. - What\, shall this speech be spoke for our excuse\,\n\nOr shall we on withou - t apology?\n\nBen. The date is out of such prolixity:\n\nWe'll have no Cupi - d hood-wink'd with a scarf\,\n\nBearing a Tartar's painted bow of lath\,\n\ - nScaring the ladies like a crow-keeper\;\n\nNor no without-book prologue\, - faintly spoke\n\nAfter the prompter\, for our entrance:\n\nBut\, let them m - easure us by what they will\,\n\nWe'll measure them a measure\, and be gone - .\n\nRom. Give me a torch\; I am not for this ambling:\n\nBeing but heavy\, - I will bear the light.\n\nMer. Nay\, gentle Romeo\, we must have you dance - .\n\nRom. Not I\, believe me. You have dancing shoes\,\n\nWith nimble soles - \; I have a soul of lead\,\n\nSo stakes me to the ground\, I cannot move.\n - \nMer. You are a lover: borrow Cupid's wings\,\n\nAnd soar with them above - a common bound.\n\nRom. I am too sore enpierced with his shaft\,\n\nTo soar - with his light feathers\; and so bound\,\n\nI cannot bound a pitch above d - ull woe:\n\nUnder love's heavy burden do I sink.\n\nMer. And\, to sink in i - t\, should you burden love\;\n\nToo great oppression for a tender thing.\n\ - nRom. Is love a tender thing? it is too rough\,\n\nToo rude\, too boisterou - s\; and it pricks like thorn.\n\nMer. If love be rough with you\, be rough - with love\;\n\nPrick love for pricking\, and you beat love down.—\n\n\n\nGi - ve me a case to put my visage in: [Putting on a mask.\n\nA visor for a viso - r!—what care I\,\n\nWhat curious eye doth quote deformities?\n\nHere are th - e beetle-brows shall blush for me.\n\nBen. Come\, knock\, and enter\; and n - o sooner in\,\n\nBut every man betake him to his legs.\n\nRom. A torch for - me: let wantons\, light of heart\,\n\nTickle the senseless rushes with thei - r heels\;\n\nFor I am proverb'd with a grandsire phrase\,—\n\nI'll be a can - dle-holder\, and look on:\n\nThe game was ne'er so fair\, and I am done.\n\ - nMer. Tut! dun's the mouse\, the constable's own word.\n\nIf thou art dun\, - we'll draw thee from the mire\n\nOf this save-reverence love\, wherein tho - u stick'st\n\nUp to the ears.—Come\, we burn day-light\, ho.\n\nRom. Nay\, - that's not so.\n\nMer. I mean\, sir\, in delay\n\nWe waste our lights in va - in\, like lamps by day.\n\nTake our good meaning\, for our judgment sits\n\ - nFive times in that\, ere once in our five wits.\n\nRom. And we mean well i - n going to this mask\,\n\nBut 'tis no wit to go.\n\nMer. Why\, may one ask? - \n\nRom. I dreamt a dream to-night?\n\nMer. And so did I.\n\nRom. Well\, wh - at was yours?\n\nMer. That dreamers often lie.\n\nRom. In bed asleep\, whil - e they do dream things true.\n\nMer. O! then\, I see\, queen Mab hath been - with you.\n\nShe is the fairies' midwife\; and she comes\n\nIn shape no big - ger than an agate-stone\n\nOn the fore-finger of an alderman\,\n\nDrawn wit - h a team of little atomies\n\nOver men's noses as they lie asleep:\n\nHer w - aggon-spokes made of long spinners' legs\;\n\nThe cover\, of the wings of g - rasshoppers\;\n\nThe traces\, of the smallest spider's web\;\n\nThe collars - \, of the moonshine's watery beams:\n\nHer whip\, of cricket's bone\; the l - ash\, of film:\n\nHer waggoner\, a small grey-coated gnat\,\n\nNot half so - big as a round little worm\n\nPrick'd from the lazy finger of a maid.\n\nHe - r chariot is an empty hazel-nut\,\n\nMade by the joiner squirrel\, or old g - rub\,\n\nTime out of mind the fairies' coach-makers.\n\nAnd in this state s - he gallops night by night\n\nThrough lovers' brains\, and then they dream o - f love: ​\n\nOn courtiers' knees\, that dream on court'sies straight:\n\nO' - er lawyers' fingers\, who straight dream on fees:\n\nO'er ladies' lips\, wh - o straight on kisses dream\;\n\nWhich oft the angry Mab with blisters plagu - es\,\n\nBecause their breaths with sweet-meats tainted are.\n\nSometime she - gallops o'er a courtier's nose\,\n\nAnd then dreams he of smelling out a s - uit:\n\nAnd sometime comes she with a tithe-pig's tail\,\n\nTickling a pars - on's nose as 'a lies asleep\;\n\nThen he dreams of another benefice.\n\nSom - etime she driveth o'er a soldier's neck\,\n\nAnd then dreams he of cutting - foreign throats\,\n\nOf breaches\, ambuscadoes\, Spanish blades\,\n\nOf hea - lths five fathom deep\; and then anon\n\nDrums in his ear\, at which he sta - rts\, and wakes\;\n\nAnd\, being thus frighted\, swears a prayer or two\,\n - \nAnd sleeps again. This is that very Mab\,\n\nThat plats the manes of hors - es in the night\;\n\nAnd bakes the elf-locks in foul sluttish hairs\,\n\nWh - ich\, once untangled\, much misfortune bodes.\n\nThis is the hag\, when mai - ds lie on their backs\,\n\nThat presses them\, and learns them first to bea - r\,\n\nMaking them women of good carriage.\n\nThis\, is she—\n\nRom. Peace\ - , peace! Mercutio\, peace!\n\nThou talk'st of nothing.\n\nMer. True\, I tal - k of dreams\,\n\nWhich are the children of an idle brain\,\n\nBegot of noth - ing but vain fantasy\;\n\nWhich is as thin of substance as the air\;\n\nAnd - more inconstant than the wind\, who woos\n\nEven now the frozen bosom of t - he north\,\n\nAnd\, being anger'd\, puffs away from thence\,\n\nTurning his - face to the dew-dropping south.\n\nBen. This wind\, you talk of\, blows us - from ourselves\;\n\nSupper is done\, and we shall come too late.\n\nRom. I - fear\, too early\; for my mind misgives\,\n\nSome consequence\, yet hangin - g in the stars\,\n\nShall bitterly begin his fearful date\n\nWith this nigh - t's revels\; and expire the term\n\nOf a despised life\, clos'd in my breas - t\,\n\nBy some vile forfeit of untimely death:\n\nBut He\, that hath the st - eerage of my course\,\n\nDirect my sail.—On\, lusty gentlemen.\n\nBen. Stri - ke\, drum. [Exeunt\n\n\n\n\n\nAn image should appear at this position in th - e text.\n\nIf you are able to provide it\, see Wikisource:Image guidelines - and Help:Adding images for guidance.\n\n\n\n('Court-cupboard\,' and Plate.) - \n\n\n\n\n\nScene V.—A Hall in Capulet's House.\n\nMusicians waiting. Enter - Servants.\n\n\n\n1 Serv. Where's Potpan\, that he helps not to take away? - he shift a trencher! he scrape a trencher!\n\n2 Serv. When good manners sha - ll lie all in one or two men's hands\, and they unwashed too\, 'tis a foul - thing.\n\n1 Serv. Away with the joint-stools\, remove the court-cupboard\, - look to the plate.—Good thou\, save ​me a piece of marchpane\; and\, as tho - u lovest me\, let the porter let in Susan Grindstone\, and Nell.—Antony! an - d Potpan!\n\n2 Serv. Ay\, boy\; ready.\n\n1 Serv. You are looked for\, and - called for\, asked for\, and sought for\, in the great chamber.\n\n2 Serv. - We cannot be here and there too.—Cheerly\, boys: be brisk awhile\, and the - longer liver take all. [They retire behind.\n\nEnter Capulet\, &c.\, with t - he Guests\, and the Maskers.\n\n\n\nCap. Welcome\, gentlemen! Ladies that h - ave their toes\n\nUnplagu'd with corns\, will have a bout with you:—\n\nAh - ha\, my mistresses! which of you all\n\nWill now deny to dance? she that ma - kes dainty\, she\,\n\nI'll swear\, hath corns. Am I come near you now?\n\nY - ou are welcome\, gentlemen! I have seen the day\,\n\nThat I have worn a vis - or\, and could tell\n\nA whispering tale in a fair lady's ear\,\n\nSuch as - would please:—'tis gone\, 'tis gone\, 'tis gone.\n\nYou are welcome\, gentl - emen!—Come\, musicians\, play.\n\n\n\nA hall! a hall! give room\, and foot - it\, girls. [Music plays\, and they dance.\n\nMore light\, ye knaves! and t - urn the tables up\,\n\nAnd quench the fire\, the room is grown too hot.—\n\ - nAh! sirrah\, this unlook'd-for sport comes well.\n\nNay\, sit\, nay\, sit\ - , good cousin Capulet\,\n\nFor you and I are past our dancing days:\n\nHow - long is't now\, since last yourself and I\n\nWere in a mask?\n\n2 Cap. By'r - lady\, thirty years.\n\n1 Cap. What\, man! 'tis not so much\, 'tis not so - much:\n\n'Tis since the nuptial of Lucentio\,\n\nCome Pentecost as quickly - as it will\,\n\nSome five and twenty years\; and then we mask'd.\n\n2 Cap. - 'Tis more\, 'tis more: his son is elder\, sir\;\n\nHis son is thirty.\n\n1 - Cap. Will you tell me that?\n\nHis son was but a ward two years ago.\n\nRom - . What lady is that\, which doth enrich the hand\n\nOf yonder knight?\n\nSe - rv. I know not\, sir.\n\nRom. O! she doth teach the torches to burn bright. - \n\nHer beauty hangs upon the cheek of night\n\nLike a rich jewel in an Æth - iop's ear\;\n\nBeauty too rich for use\, for earth too dear!\n\nSo shows a - snowy dove trooping with crows\,\n\nAs yonder lady o'er her fellows shows.\ - n\nThe measure done\, I'll watch her place of stand\,\n\nAnd\, touching her - s\, make blessed my rude hand.\n\nDid my heart love till now? forswear it\, - sight!\n\nI never saw true beauty till this night.\n\nTyb. This\, by his v - oice\, should be a Montague.—\n\nFetch me my rapier\, boy.—What! dares the - slave\n\nCome hither\, cover'd with an antic face\,\n\nTo fleer and scorn a - t our solemnity?\n\nNow\, by the stock and honour of my kin\,\n\nTo strike - him dead I hold it not a sin.\n\n1 Cap. Why\, how now\, kinsman! wherefore - storm you so?\n\nTyb. Uncle\, this is a Montague\, our foe\;\n\nA villain\, - that is hither come in spite\,\n\nTo scorn at our solemnity this night.\n\ - n1 Cap. Young Romeo is it?\n\nTyb. 'Tis he\, that villain Romeo.\n\n1 Cap. - Content thee\, gentle coz\, let him alone\,\n\nHe bears him like a portly g - entleman\;\n\nAnd\, to say truth\, Verona brags of him\,\n\nTo be a virtuou - s and well-govern'd youth.\n\nI would not for the wealth of all this town\, - \n\nHere\, in my house\, do him disparagement\;\n\nTherefore\, be patient\, - take no note of him:\n\nIt is my will\; the which if thou respect\,\n\nSho - w a fair presence\, and put off these frowns\,\n\nAn ill-beseeming semblanc - e for a feast.\n\nTyb. It fits\, when such a villain is a guest.\n\nI'll no - t endure him.\n\n1 Cap. He shall be endur'd:\n\nWhat! goodman boy!—I say\, - he shall\;—go to\;—\n\nAm I the master here\, or you? go to.\n\nYou'll not - endure him! God shall mend my soul—\n\nYou'll make a mutiny among my guests - .\n\nYou will set cock-a-hoop! you'll be the man!\n\nTyb. Why\, uncle\, 'ti - s a shame.\n\n1 Cap. Go to\, go to\;\n\nYou are a saucy boy.—Is't so\, inde - ed?—\n\nThis trick may chance to scath you\;—I know what.\n\nYou must contr - ary me! marry\, 'tis time—\n\nWell said\, my hearts!—You are a princox\; go - :—\n\nBe quiet\, or—More light\, more light!—for shame!\n\nI'll make you qu - iet\;—What!—Cheerly\, my hearts!\n\nTyb. Patience perforce with wilful chol - er meeting\,\n\nMakes my flesh tremble in their different greeting.\n\nI wi - ll withdraw: but this intrusion shall\,\n\n\n\nNow seeming sweet\, convert - to bitter gall. [Exit.\n\nRom. If I profane with my unworthiest hand [To Ju - liet.\n\n⁠This holy shrine\, the gentle fine is this\,—\n\nMy lips\, two bl - ushing pilgrims\, ready stand\n\n⁠To smooth that rough touch with a tender - kiss.\n\nJul. Good pilgrim\, you do wrong your hand too much\,\n\n⁠Which ma - nnerly devotion shows in this\;\n\nFor saints have hands that pilgrims' han - ds do touch\,\n\n⁠And palm to palm is holy palmers' kiss.\n\nRom. Have not - saints lips\, and holy palmers too?\n\nJul. Ay\, pilgrim\, lips that they m - ust use in prayer.\n\nRom. O! then\, dear saint\, let lips do what hands do - \;\n\nThey pray\, grant thou\, lest faith turn to despair.\n\nJul. Saints d - o not move\, though grant for prayers' sake.\n\nRom. Then move not\, while - my prayer's effect I take.\n\n\n\nThus from my lips\, by thine\, my sin is - purg'd. [Kissing her.\n\nJul. Then have my lips the sin that they have took - .\n\nRom. Sin from my lips? O\, trespass sweetly urg'd!\n\nGive me my sin a - gain.\n\nJul. You kiss by the book.\n\nNurse. Madam\, your mother craves a - word with you.\n\nRom. What is her mother?\n\nNurse. Marry\, bachelor\,\n\n - Her mother is the lady of the house\,\n\nAnd a good lady\, and a wise\, and - virtuous.\n\nI nurs'd her daughter\, that you talk'd withal\;\n\nI tell yo - u—he that can lay hold of her\n\nShall have the chinks.\n\n\n\n​Rom. Is she - a Capulet?\n\nO\, dear account! my life is my foe's debt.\n\nBen. Away\, b - egone: the sport is at the best.\n\nRom. Ay\, so I fear\; the more is my un - rest.\n\n1 Cap. Nay\, gentlemen\, prepare not to be gone\;\n\nWe have a tri - fling foolish banquet towards.—\n\nIs it e'en so? Why then\, I thank you al - l\;\n\nI thank you\, honest gentlemen\; good night:—\n\nMore torches here!— - Come on\, then let's to bed.\n\nAh\, sirrah\, by my fay\, it waxes late\;\n - \n\n\nI'll to my rest. [Exeunt all but Juliet and Nurse.\n\nJul. Come hithe - r\, nurse. What is yond' gentleman?\n\nNurse. The son and heir of old Tiber - io.\n\nJul. What's he\, that now is going out of door?\n\nNurse. Marry\, th - at\, I think\, be young Petruchio.\n\nJul. What's he\, that follows here\, - that would not dance?\n\nNurse. I know not.\n\nJul. Go\, ask his name.—If h - e be married\,\n\nMy grave is like to be my wedding bed.\n\nNurse. His name - is Romeo\, and a Montague\;\n\nThe only son of your great enemy.\n\nJul. M - y only love sprung from my only hate!\n\nToo early seen unknown\, and known - too late!\n\nProdigious birth of love it is to me\,\n\nThat I must love a - loathed enemy.\n\nNurse. What's this? what's this?\n\nJul. A rhyme I learn' - d even now\n\nOf one I danc'd withal. [One calls within\, Juliet!\n\nNurse. - Anon\, anon:\n\nCome\, let's away\; the strangers all are gone. [Exeunt.\n - \n\n\n\n\nEnter Chorus.\n\n\n\nNow old desire doth in his death-bed lie\,\n - \nAnd young affection gapes to be his heir:\n\nThat fair\, for which love g - roan'd for\, and would die\,\n\nWith tender Juliet match'd is now not fair. - \n\nNow Romeo is belov'd\, and loves again\,\n\nAlike bewitched by the char - m of looks\;\n\nBut to his foe suppos'd he must complain\,\n\nAnd she steal - love's sweet bait from fearful hooks:\n\nBeing held a foe\, he may not hav - e access\n\nTo breathe such vows as lovers use to swear\;\n\nAnd she as muc - h in love\, her means much less\n\nTo meet her new-beloved anywhere:\n\nBut - passion lends them power\, time\, means\, to meet\,\n\n\n\nTempering extre - mities with extreme sweet. [Exit.\n\n\n\n\n\nAn image should appear at this - position in the text.\n\nIf you are able to provide it\, see Wikisource:Im - age guidelines and Help:Adding images for guidance.\n\n\n\n\n\nAbout this d - igital edition\n\n\nThis e-book comes from the online library Wikisource[1] - . This multilingual digital library\, built by volunteers\, is committed to - developing a free accessible collection of publications of every kind: nov - els\, poems\, magazines\, letters...\n\nWe distribute our books for free\, - starting from works not copyrighted or published under a free license. You - are free to use our e-books for any purpose (including commercial exploitat - ion)\, under the terms of the Creative Commons Attribution-ShareAlike 3.0 U - nported[2] license or\, at your choice\, those of the GNU FDL[3].\n\nWikiso - urce is constantly looking for new members. During the realization of this - book\, it's possible that we made some errors. You can report them at this - page[4].\n\nThe following users contributed to this book:\n\nAngelprincess7 - 2\n\nDjr13\n\nThomasBot\n\nBirgitteSB\n\nMpaa\n\nBeleg Tâl\n\nEinstein95\n\ - nKathleen.wright5\n\nEncycloPetey\n\nDariyman\n\n\n\n\n\n* * *\n\n\n\n↑ htt - p://wikisource.org\n\n↑ http://www.creativecommons.org/licenses/by-sa/3.0\n - \n↑ http://www.gnu.org/copyleft/fdl.html\n\n↑ http://wikisource.org/wiki/Wi - kisource:Scriptorium\n\n\n\n\n\n -STATUS:CONFIRMED -DTSTART;TZID=Europe/Berlin:20200221T180000 -DTEND;TZID=Europe/Berlin:20200221T190000 -END:VEVENT -BEGIN:VTIMEZONE -TZID:Europe/Berlin -X-LIC-LOCATION:Europe/Berlin -BEGIN:DAYLIGHT -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -TZNAME:CEST -DTSTART:19700329T020000 -RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU -END:DAYLIGHT -BEGIN:STANDARD -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -TZNAME:CET -DTSTART:19701025T030000 -RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU -END:STANDARD -END:VTIMEZONE -END:VCALENDAR \ No newline at end of file diff --git a/tests/fixtures/Romeo-and-Juliet.txt b/tests/fixtures/Romeo-and-Juliet.txt deleted file mode 100644 index 9a30625a..00000000 --- a/tests/fixtures/Romeo-and-Juliet.txt +++ /dev/null @@ -1,1746 +0,0 @@ -Romeo and Juliet (The Illustrated Shakespeare) - - - William Shakespeare - - - - - -1847 - - - - - -Exported from Wikisource on 02/23/20 - - - - - -This work may need to be standardized using Wikisource's style guidelines. - -If you'd like to help, please review the help pages. - - - - - -PROLOGUE - - -​ PROLOGUE - - - -CHORUS. - - - -Two households, both alike in dignity, - -In fair Verona, where we lay our scene, - -From ancient grudge break to new mutiny, - -Where civil blood makes civil hands unclean. - -From forth the fatal loins of these two foes - -A pair of star-cross'd lovers take their life; - -Whose misadventur'd piteous overthrows - -Do, with their death, bury their parents' strife. - -The fearful passage of their death-mark'd love, - -And the continuance of their parents' rage, - -Which, but their children's end, nought could remove, - -Is now the two hours' traffic of our stage; - -The which if you with patient ears attend, - -What here shall miss, our toil shall strive to mend. - - - - - -An image should appear at this position in the text. - -If you are able to provide it, see Wikisource:Image guidelines and Help:Adding images for guidance. - - - - - -ACT I - - -​ - - - -Scene I.—A Public Place. - -Enter Sampson and Gregory, armed with Swords and Bucklers. - - - -Sam. Gregory, on my word, we'll not carry coals. - -Gre. No, for then we should be colliers. - -Sam. I mean, an we be in choler, we'll draw. - -Gre. Ay, while you live, draw your neck out of the collar. - -Sam. I strike quickly, being moved. - -Gre. But thou art not quickly moved to strike. - -Sam. A dog of the house of Montague moves me. - -Gre. To move is to stir, and to be valiant is to stand: therefore, if thou art moved, thou run'st away. - -Sam. A dog of that house shall move me to stand. I will take the wall of any man or maid of Montague's. - -Gre. That shows thee a weak slave; for the weakest goes to the wall. - -Sam. 'Tis true; and therefore women, being the weaker vessels,are ever thrust to the wall:—therefore, I will push Montague's men from the wall, and thrust his maids to the wall. - -Gre. The quarrel is between our masters, and us their men. - -Sam. 'Tis all one, I will show myself a tyrant: when I have fought with the men, I will be civil with the maids; I will cut off their heads. - -Gre. The heads of the maids? - -Sam. Ay, the heads of the maids, or their maidenheads; take it in what sense thou wilt. - -Gre. They must take it in sense, that feel it. - -Sam. Me they shall feel, while I am able to stand; and 'tis known, I am a pretty piece of flesh. - -Gre. 'Tis well, thou art not fish; if thou hadst, ​thou hadst been poor John. Draw thy tool; here comes two of the house of the Montagues. - - - - - -Enter Abraham and Balthasar. - - - -Sam. My naked weapon is out: quarrel, I will back thee. - -Gre. How! turn thy back, and run? - -Sam. Fear me not. - -Gre. No marry: I fear thee! - -Sam. Let us take the law of our sides; let them begin. - -Gre. I will frown as I pass by, and let them take it as they list. - -Sam. Nay, as they dare. I will bite my thumb at them; which is a disgrace to them, if they bear it. - -Abr. Do you bite your thumb at us, sir? - -Sam. I do bite my thumb, sir. - -Abr. Do you bite your thumb at us, sir? - - - -An image should appear at this position in the text. - -If you are able to provide it, see Wikisource:Image guidelines and Help:Adding images for guidance. - - - -Sam. Is the law of our side, if I say—ay? - -Gre. No. - -Sam. No, sir, I do not bite my thumb at you, sir; but I bite my thumb, sir. - -Gre. Do you quarrel, sir? - -Abr. Quarrel, sir? no, sir. - -Sam. If you do, sir, I am for you: I serve as good a man as you. - -Abr. No better. - -Sam. Well, sir. - - - - - -Enter Benvolio, at a distance. - - - -Gre. Say—better: here comes one of my master's kinsmen. - -Sam. Yes, better, sir. - -Abr. You lie. - -Sam. Draw, if you be men.—Gregory, remember thy swashing blow. [They fight. - -Ben. Part, fools! put up your swords; you know not what you do. [Beats down their Swords. - - - - - -Enter Tybalt. - - - -Tyb. What! art thou drawn among these heartless hinds? - -Turn thee, Benvolio, look upon thy death. - -Ben. I do but keep the peace: put up thy sword, - -Or manage it to part these men with me. - -Tyb. What! drawn, and talk of peace? I hate the word, - -As I hate hell, all Montagues, and thee. - - - -Have at thee, coward. [They fight. - -​ Enter several persons of both Houses, who join the fray; then enter Citizens, with clubs or partisans. - - - -1 Cit. Clubs, bills, and partisans! strike! beat them down! - -Down with the Capulets! down with the Montagues! - - - - - -Enter Capulet in his gown; and Lady Capulet. - - - -Cap. What noise is this?—Give me my long sword, ho! - -La. Cap. A crutch, a crutch!—Why call you for a sword? - -Cap. My sword, I say!—Old Montague is come, - -And flourishes his blade in spite of me. - - - - - -Enter Montague and Lady Montague. - - - -Mon. Thou villain Capulet!—Hold me not; let me go. - -La. Mon. Thou shalt not stir one foot to seek a foe. - - - - - -Enter Prince, with his train. - - - -Prin. Rebellious subjects, enemies to peace, - -Profaners of this neighbour-stained steel— - -Will they not hear?—what ho! you men, you beasts, - -That quench the fire of your pernicious rage - -With purple fountains issuing from your veins, - -On pain of torture, from those bloody hands - -Throw your mis–temper'd weapons to the ground, - -And hear the sentence of your moved prince.— - -Three civil brawls, bred of an airy word, - -By thee, old Capulet, and Montague, - -Have thrice disturb'd the quiet of our streets; - -And made Verona's ancient citizens - -Cast by their grave beseeming ornaments, - -To wield old partisans, in hands as old, - -Canker'd with peace, to part your canker'd hate. - -If ever you disturb our streets again, - -Your lives shall pay the forfeit of the peace: - -For this time, all the rest depart away. - -You Capulet, shall go along with me; - -And, Montague, come you this afternoon, - -To know our further pleasure in this case, - -To old Free-town, our common judgment-place. - - - -Once more, on pain of death, all men depart. [Exeunt Prince and Attendants; Capulet, Lady Capulet, Tybalt, Citizens, and Servants. - -Mon. Who set this ancient quarrel new abroach? - -Speak, nephew, were you by when it began? - -Ben. Here were the servants of your adversary, - -And yours, close fighting ere I did approach. - -I drew to part them: in the instant came - -The fiery Tybalt, with his sword prepar'd; - -Which, as he breath'd defiance to my ears, - -He swung about his head, and cut the winds, - -Who, nothing hurt withal, hiss'd him in scorn. - -While we were interchanging thrusts and blows, - -Came more and more, and fought on part and part, - -Till the prince came, who parted either part. - -La. Mon. O! where is Romeo?—saw you him to-day? - -Right glad I am he was not at this fray. - -Ben. Madam, an hour before the worshipp'd sun - -Peer'd forth the golden window of the east, - -A troubled mind drave me to walk abroad; - -Where, underneath the grove of sycamore - -That westward rooteth from the city's side, - -So early walking did I see your son. - -Towards him I made; but he was 'ware of me, - -And stole into the covert of the wood: - -I, measuring his affections by my own, - -Which then most sought, where most might not be found, - -Being one too many by my weary self, - -Pursu'd my humour, not pursuing his, - -And gladly shunn'd who gladly fled from me. - -Mon. Many a morning hath he there been seen, - -With tears augmenting the fresh morning's dew, - -Adding to clouds more clouds with his deep sighs: - -But all so soon as the all-cheering sun - -Should in the furthest east begin to draw - -The shady curtains from Aurora's bed, - -Away from the light steals home my heavy son, - -And private in his chamber pens himself; - -Shuts up his windows, locks fair daylight out, - -And makes himself an artificial night. - -Black and portentous must this humour prove, - -Unless good counsel may the cause remove. - -Ben. My noble uncle, do you know the cause? - -Mon. I neither know it, nor can learn of him. - -Ben. Have you importun'd him by any means? - -Mon. Both by myself, and many other friends: - -But he, his own affections' counsellor, - -Is to himself—I will not say, how true— - -But to himself so secret and so close, - -So far from sounding and discovery, - -As is the bud bit with an envious worm. - -Ere he can spread his sweet leaves to the air, - -Or dedicate his beauty to the sun. - -Could we but learn from whence his sorrows grow, - -We would as willingly give cure, as know. - - - - - -Enter Romeo, at a distance. - - - -Ben. See, where he comes: so please you, step aside; - -I'll know his grievance, or be much denied. - -Mon. I would, thou wert so happy by thy stay, - - - -To hear true shrift.—Come, madam, let's away. [Exeunt Montague and Lady. - -Ben. Good morrow, cousin. - -Rom. Is the day so young? - -Ben. But new struck nine. - -Rom. Ah me! sad hours seem long. - -Was that my father that went hence so fast? - -Ben. It was. What sadness lengthens Romeo's hours? - -Rom. Not having that, which, having, makes them short. - -Ben. In love? - -Rom. Out. - -Ben. Of love? - -Rom. Out of her favour, where I am in love. - -Ben. Alas, that love, so gentle in his view, - -Should be so tyrannous and rough in proof! - -Rom. Alas, that love, whose view is muffled still, - -Should, without eyes, see pathways to his will! - -Where shall we dine?—O me!—What fray was here? - -Yet tell me not, for I have heard it all. - -Here's much to do with hate, but more with love: - -Why then, O brawling love! O loving hate! - -O any thing, of nothing first created! - -O heavy lightness! serious vanity! - -Mis-shapen chaos of well-seeming forms! - -Feather of lead, bright smoke, cold fire, sick health! - -Still-waking sleep, that is not what it is!— - -This love feel I, that feel no love in this. - -Dost thou not laugh? - -Ben. No, coz; I rather weep. - -Rom. Good heart, at what? - - - -​Ben. At thy good heart's oppression. - -Rom. Why, such is love's transgression.- - -Griefs of mine own lie heavy in my breast; - -Which thou wilt propagate, to have it presse'd - -With more of thine: this love, that thou hast shown, - -Doth add more grief to too much of mine own. - -Love is a smoke, made with the fume of sighs; - -Being purg'd, a fire sparkling in lovers' eyes; - -Being vex'd, a sea nourish'd with lover's tears: - -What is it else? a madness most discreet, - -A choking gall, and a preserving sweet. - - - -Farewell, my coz. [Going. - -Ben. Soft, I will go along: - -And if you leave me so, you do me wrong. - -Rom. Tut! I have lost myself: I am not here; - -This is not Romeo, he's some other where. - -Ben. Tell me in sadness, who is it that you love. - -Rom. What! shall I groan, and tell thee? - -Ben Groan! why, no; - -But sadly tell me, who. - -Rom. Bid a sick man in sadness make his will; - -A word ill urg'd to one that is so ill.- - -In sadness, cousin, I do love a woman. - -Ben. I aim'd so near, when I suppos'd you lov'd. - -Rom. A right good mark-man! And she's fair I love. - -Ben. A right fair mark, fair coz, is soonest hit. - -Rom. Well, in that hit, you miss: she'll not be hit. - -With Cupid's arrow. She hath Dian's wit; - -And in strong proof of chastity well arm'd, - -From love's weak childish bow she lives unharm'd - -She will not stay the siege of loving terms, - -Nor bide th' encounter of assailing eyes, - -Nor ope her lap to saint-seducing gold: - -O! she is rich in beauty; only poor, - -That when she dies with beauty dies her store. - -Ben. Then she hath sworn, that she will still live chaste? - -Rom. She hath, and in that sparing makes huge waste; - -For beauty, starv'd with her severity, - -Cuts beauty off from all posterity. - -She is too fair, too wise; wisely too fair, - -To merit bliss by making me despair: - -She hath forsworn to love, and in that vow - -Do I live dead, that live it to tell it now. - -Ben. Be rul'd by me; forget to think of her. - -Rom. O! teach me how I should forget to think. - -Ben. By giving liberty unto thine eyes: - -Examine other beauties. - -Rom. 'Tis the way - -To call her's, exquisite, in question more. - -These happy masks, that kiss fair ladies' brows, - -Being black, put us in mind they hide the fair: - -He, that is stricken blind, cannot forget - -The precious treasure of his eyesight lost. - -Show me a mistress that is passing fair, - -What doth her beauty serve, but as a note - -Where I may read who pass'd that passing fair? - -Farewell: thou canst not teach me to forget. - -Ben. I'll pay that doctrine, or else die in debt. [Exeunt. - - - - - -An image should appear at this position in the text. - -If you are able to provide it, see Wikisource:Image guidelines and Help:Adding images for guidance. - - - -(Verona.) - - - - - -​ Scene II.—A Street. - -Enter Capulet, Paris, and Servant. - - - -Cap. But Montague is bound as well as I, - -In penalty alike; and 'tis not hard, I think, - -For men so old as we to keep the peace. - -Par. Of honourable reckoning are you both; - -And pity 'tis, you liv'd at odds so long. - -But now, my lord, what say you to my suit? - -Cap. But saying o'er what I have said before; - -My child is yet a stranger in the world, - -She hath not seen the change of fourteen years: - -Let two more summers wither in their pride, - -Ere we may think her ripe to be bride. - -Par. Younger than she are happy mothers made. - -Cap. And too soon marr'd are those so early made. - -Earth hath swallowed all my hopes but she, - -She is the hopeful lady of my earth: - -But woo her, gentle Paris, get her heart, - -My will to her consent is but a part; - -An she agree, within her scope of choice - -Lies my consent and fair according voice. - -This night I hold an old accustom'd feast, - -Whereto I have invited many a guest, - -Such as I love; and you among the store, - -One more most welcome, makes my number more. - -At my poor house look to behold this night - -Earth-treading stars, that make dark heaven light: - -Such comfort, as do lusty young men feel, - -When well-apparel'd April on the heel - -Of limping winter treads, even such delight - -Among fresh female buds shall you see this night - -Inherit at my house: hear all, all see, - -And like her most, whose merit most shall be: - -Which, on more view of many, mine being one, - -May stand in number, though in reckoning none- - -Come, go with me—Go, sirrah, trudge about - -Through fair Verona; find those persons out, - - - -Whose names are written there, and to give them say, [Giving a paper. - -My house and welcome on their pleasure stay. [Exeunt Capulet and Paris. - -Serv. Find them out, whose names are written here? It is written, that the shoemaker should meddle with his yard, and the tailor with his last, the fisher with his pencil, and the painter with his nets; but I am sent to find those persons, whose names are here writ, and can never find what names the writing person hath here writ. I must to the learned:—in good time. - - - - - -Enter Benvolio and Romeo. - - - -Ben. Tut, man! one fire burns out another's burning, - -⁠One pain lessen'd by another's anguish; - -Turn giddy, and be holp by backward turning; - -⁠One desperate grief cures with another's languish: - -Take thou some new infection to thy eye, - -And the rank poison of the old will die. - -Rom. Your plantain leaf is excellent for that. - -Ben. For what, I pray thee? - -Rom. For your broken shin. - -Ben. Why, Romeo, are thou mad? - -Rom. Not mad, but bound more than a madman is: - -Shut up in prison, kept without my food, - -Whipp'd and tormented, and—Good-den, good fellow. - -Serv. God gi' good den.—I pray, sir, can you read? - -Rom. Ay, mine own fortune in my misery. - -Serv. Perhaps you have learn'd it without book; but I pray, can you read anything you see? - -Rom. Ay, if I know the letters, and the language. - -Serv. Ye say honestly. Rest you merry. - -Rom. Stay, fellow; I can read. [Reads. - -"Signior Martino, and his wife, and daughters; County Anselme, and his beauteous sisters; the lady widow of Vitruvio; Signior Placentio, and his lovely nieces; Mercutio, and his brother Valentine; mine uncle Capulet, his wife, and daughters; my fair niece Rosaline; Livia; Signior Valentio, and his cousin Tybalt; Lucio, and the lively Helena." - -A fair assembly; whither should they come? - -Serv. Up. - -Rom. Whither? to supper? - -Serv. To our house. - -Rom. Whose house? - -Serv. My master's. - -Rom. Indeed, I should have asked you that before. - -Serv. Now, I'll tell you without asking. My master is the great rich Capulet; and if you be not of the house of Montagues, I pray, come and crush a cup of wine. Rest you merry. [Exit. - -Ben. At this same ancient feast of Capulet's - -Sups the fair Rosaline, whom thou so lov'st, - -With all the admired beauties of Verona: - -Go thither; and, with unattainted eye, - -Compare her face with some that I shall show, - -And I will make thee think thy swan a crow. - -Rom. When the devout religion of mine eye - -⁠Maintains such falsehood, then turns tears to fires; - -And these, who, often drown'd, could never die, - -⁠Transparent heretics, be burnt for liars. - -One fairer than my love! the all-seeing sun - -Ne'er saw her match, since first the world begun. - -Ben. Tut! you saw her fair, none else being by, - -Herself pois'd with herself in either eye; - -But in those crystal scales, let there be weigh'd - -Your lady's love against some other maid, - -That I will show you shining at this feast, - -And she shall scant show well, that now shows best. - -Rom. I'll go along, no such sight to be shown, - - - -But to rejoice in splendour of mine own. [Exeunt. - - - - - -Scene III—A Room in Capulet's House. - -Enter Lady Capulet and Nurse. - - - -La. Cap. Nurse, where's my daughter? call her forth to me. - -Nurse. Now, by my maiden-head at twelve year old, - -I bade her come—What, lamb! what, lady-bird!— - -God forbid!—where's this girl?—what, Juliet! - - - - - -Enter Juliet. - - - -Jul. How now! who calls? - -Nurse. Your mother. - -Jul. Madam, I am here. - -What is your will? - -La. Cap. This is the matter.—Nurse, give leave awhile, - -We must talk in secret.—Nurse, come back again: - -I have remember'd me, thou shalt hear our counsel. - -Thou know'st my daughter's of a pretty age. - -Nurse. 'Faith, I can tell her age unto an hour. - -La. Cap. She's not fourteen. - -Nurse. I'll lay fourteen on my teeth. - -And yet to my teen be it spoken I have but four, - -She is not fourteen. How long is it now - -To Lammas-tide? - - - -​La. Cap. A fortnight, and odd days. - -Nurse. Even or odd, of all days in the year, - -Come Lammas-eve at night shall she be fourteen. - -Susan and she,—God rest all Christian souls!— - -Were of an age.—Well, Susan is with God; - -She was too good for me. But, as I said, - -On Lammas-eve at night shall she be fourteen; - -That shall she, marry: I remember it well. - -'Tis since the earthquake now eleven years; - -And she was wean'd,—I never shall forget it,— - -Of all the days of the year, upon that day; - -For I had then laid wormwood to my dug, - -Sitting in the sun under the dove-house wall: - -My lord and you were then at Mantua.— - -Nay, I do bear a brain:—but, as I said, - -When it did taste the wormwood on the nipple - -Of my dug, and felt it bitter, pretty fool, - -To see it tetchy, and fall out with the dug! - -Shake, quoth the dove-house: 'twas no need, I trow, - -To bid me trudge. - -And since that time it is eleven years; - -For then she could stand alone; nay, by the rood, - -She could have run and waddled all about, - -For even the day before she broke her brow: - -And then my husband—God be with his soul! - -'A was a merry man,—took up the child: - -"Yea," quoth he, "dost thou fall upon thy face? - - - -Thou wilt fall backward, when thou hast more wit; - -An image should appear at this position in the text. - -If you are able to provide it, see Wikisource:Image guidelines and Help:Adding images for guidance. - - - - - -Wilt thou not, Jule?" and, by my holy-dam, - -The pretty wretch left crying, and said—"Ay." - -To see, now, how a jest shall come about! - -I warrant, an I should live a thousand years. - -I never should forget it: "Wilt thou not, Jule?" quoth he: - -And, pretty fool, it stinted, and said—"Ay." - -La. Cap. Enough of this: I pray thee: hold they peace. - -Nurse. Yes, madam. Yet I cannot choose but laugh, - -To think it should leave crying, and say—"Ay:" - -And yet, I warrant, it had upon its brow - -A bump as big as a young cockerel's stone, - -A perilous knock; and it cried bitterly. - -"Yea," quoth my husband, "fall'st upon thy face? - -Thou wilt fall backward, when thou com'st to age; - -Wilt thou not, Jule?" it stinted, and said—"Ay." - -Jul. And stint thou too, I pray thee, nurse, say I. - -Nurse. Peace, I have done. God mark thee to his grace! - -Thou was the prettiest babe that e'er I nurs'd: - -An I might live to see thee married once, - -I have my wish. - - - -​La. Cap. Marry, that marry is the very theme - -I came to talk of:—tell me, daughter Juliet, - -How stands your disposition to be married? - -Jul. It is an honour that I dream not of. - -Nurse. An honour! were not I thine only nurse, - -I would say, thou hadst sucked wisdom from thy teat. - -La. Cap. Well, think of marriage now; younger than you, - -Here in Verona, ladies of esteem, - -Are made already mothers: by my count, - -I was your mother, much upon these years - -That you are now a maid. Thus, then, in brief;— - -The valiant Paris seeks you for his love. - -Nurse. A man, young lady! lady, such a man, - -As all the world—Why, he's a man of wax. - -La. Cap. Verona's summer hath not such a flower. - -Nurse. Nay, he's a flower; in faith, a very flower. - -La. Cap. What say you? can you love the gentleman? - -This night you shall behold him at out' feast: - -Read o'er the volume of young Paris' face, - -And find delight writ there with beauty's pen. - -Examine every married lineament, - -And see how one another lends content; - -And what obscur'd in this fair volume lies, - -Find written in the margin of his eyes. - -This precious book of love, this unbound lover, - -To beautify him, only lacks a cover: - -The fish lives in the sea; and 'tis much pride, - -For fair without the fair within to hide. - -That book in many's eyes doth share the glory, - -That in gold clasps locks in the golden story; - -So shall you share all that he doth possess, - -By having him making yourself no less. - -Nurse. No less? nay, bigger: women grow by men. - -La. Cap. Speak briefly, can you like of Paris' love? - -Jul. I'll look to like, if looking liking move; - -But no more deep will I endart mine eye, - -Than your consent gives strength to make it fly. - -Enter a Servant. - - - -Serv. Madam, the guests are come, supper served up, you called, my young lady asked for, the nurse cursed in the pantry, and every thing in extremity. I must hence to wait; I beseech you, follow straight. - -La. Cap. We follow thee. Juliet, the county stays. - -Nurse. Go, girl, seek happy nights to happy days. [Exeunt. - - - - - -Scene IV.—A Street. - -Enter Romeo, Mercutio, Benvolio, with five or six Maskers, Torch-bearers, and others. - - - -Rom. What, shall this speech be spoke for our excuse, - -Or shall we on without apology? - -Ben. The date is out of such prolixity: - -We'll have no Cupid hood-wink'd with a scarf, - -Bearing a Tartar's painted bow of lath, - -Scaring the ladies like a crow-keeper; - -Nor no without-book prologue, faintly spoke - -After the prompter, for our entrance: - -But, let them measure us by what they will, - -We'll measure them a measure, and be gone. - -Rom. Give me a torch; I am not for this ambling: - -Being but heavy, I will bear the light. - -Mer. Nay, gentle Romeo, we must have you dance. - -Rom. Not I, believe me. You have dancing shoes, - -With nimble soles; I have a soul of lead, - -So stakes me to the ground, I cannot move. - -Mer. You are a lover: borrow Cupid's wings, - -And soar with them above a common bound. - -Rom. I am too sore enpierced with his shaft, - -To soar with his light feathers; and so bound, - -I cannot bound a pitch above dull woe: - -Under love's heavy burden do I sink. - -Mer. And, to sink in it, should you burden love; - -Too great oppression for a tender thing. - -Rom. Is love a tender thing? it is too rough, - -Too rude, too boisterous; and it pricks like thorn. - -Mer. If love be rough with you, be rough with love; - -Prick love for pricking, and you beat love down.— - - - -Give me a case to put my visage in: [Putting on a mask. - -A visor for a visor!—what care I, - -What curious eye doth quote deformities? - -Here are the beetle-brows shall blush for me. - -Ben. Come, knock, and enter; and no sooner in, - -But every man betake him to his legs. - -Rom. A torch for me: let wantons, light of heart, - -Tickle the senseless rushes with their heels; - -For I am proverb'd with a grandsire phrase,— - -I'll be a candle-holder, and look on: - -The game was ne'er so fair, and I am done. - -Mer. Tut! dun's the mouse, the constable's own word. - -If thou art dun, we'll draw thee from the mire - -Of this save-reverence love, wherein thou stick'st - -Up to the ears.—Come, we burn day-light, ho. - -Rom. Nay, that's not so. - -Mer. I mean, sir, in delay - -We waste our lights in vain, like lamps by day. - -Take our good meaning, for our judgment sits - -Five times in that, ere once in our five wits. - -Rom. And we mean well in going to this mask, - -But 'tis no wit to go. - -Mer. Why, may one ask? - -Rom. I dreamt a dream to-night? - -Mer. And so did I. - -Rom. Well, what was yours? - -Mer. That dreamers often lie. - -Rom. In bed asleep, while they do dream things true. - -Mer. O! then, I see, queen Mab hath been with you. - -She is the fairies' midwife; and she comes - -In shape no bigger than an agate-stone - -On the fore-finger of an alderman, - -Drawn with a team of little atomies - -Over men's noses as they lie asleep: - -Her waggon-spokes made of long spinners' legs; - -The cover, of the wings of grasshoppers; - -The traces, of the smallest spider's web; - -The collars, of the moonshine's watery beams: - -Her whip, of cricket's bone; the lash, of film: - -Her waggoner, a small grey-coated gnat, - -Not half so big as a round little worm - -Prick'd from the lazy finger of a maid. - -Her chariot is an empty hazel-nut, - -Made by the joiner squirrel, or old grub, - -Time out of mind the fairies' coach-makers. - -And in this state she gallops night by night - -Through lovers' brains, and then they dream of love: ​ - -On courtiers' knees, that dream on court'sies straight: - -O'er lawyers' fingers, who straight dream on fees: - -O'er ladies' lips, who straight on kisses dream; - -Which oft the angry Mab with blisters plagues, - -Because their breaths with sweet-meats tainted are. - -Sometime she gallops o'er a courtier's nose, - -And then dreams he of smelling out a suit: - -And sometime comes she with a tithe-pig's tail, - -Tickling a parson's nose as 'a lies asleep; - -Then he dreams of another benefice. - -Sometime she driveth o'er a soldier's neck, - -And then dreams he of cutting foreign throats, - -Of breaches, ambuscadoes, Spanish blades, - -Of healths five fathom deep; and then anon - -Drums in his ear, at which he starts, and wakes; - -And, being thus frighted, swears a prayer or two, - -And sleeps again. This is that very Mab, - -That plats the manes of horses in the night; - -And bakes the elf-locks in foul sluttish hairs, - -Which, once untangled, much misfortune bodes. - -This is the hag, when maids lie on their backs, - -That presses them, and learns them first to bear, - -Making them women of good carriage. - -This, is she— - -Rom. Peace, peace! Mercutio, peace! - -Thou talk'st of nothing. - -Mer. True, I talk of dreams, - -Which are the children of an idle brain, - -Begot of nothing but vain fantasy; - -Which is as thin of substance as the air; - -And more inconstant than the wind, who woos - -Even now the frozen bosom of the north, - -And, being anger'd, puffs away from thence, - -Turning his face to the dew-dropping south. - -Ben. This wind, you talk of, blows us from ourselves; - -Supper is done, and we shall come too late. - -Rom. I fear, too early; for my mind misgives, - -Some consequence, yet hanging in the stars, - -Shall bitterly begin his fearful date - -With this night's revels; and expire the term - -Of a despised life, clos'd in my breast, - -By some vile forfeit of untimely death: - -But He, that hath the steerage of my course, - -Direct my sail.—On, lusty gentlemen. - -Ben. Strike, drum. [Exeunt - - - - - -An image should appear at this position in the text. - -If you are able to provide it, see Wikisource:Image guidelines and Help:Adding images for guidance. - - - -('Court-cupboard,' and Plate.) - - - - - -Scene V.—A Hall in Capulet's House. - -Musicians waiting. Enter Servants. - - - -1 Serv. Where's Potpan, that he helps not to take away? he shift a trencher! he scrape a trencher! - -2 Serv. When good manners shall lie all in one or two men's hands, and they unwashed too, 'tis a foul thing. - -1 Serv. Away with the joint-stools, remove the court-cupboard, look to the plate.—Good thou, save ​me a piece of marchpane; and, as thou lovest me, let the porter let in Susan Grindstone, and Nell.—Antony! and Potpan! - -2 Serv. Ay, boy; ready. - -1 Serv. You are looked for, and called for, asked for, and sought for, in the great chamber. - -2 Serv. We cannot be here and there too.—Cheerly, boys: be brisk awhile, and the longer liver take all. [They retire behind. - -Enter Capulet, &c., with the Guests, and the Maskers. - - - -Cap. Welcome, gentlemen! Ladies that have their toes - -Unplagu'd with corns, will have a bout with you:— - -Ah ha, my mistresses! which of you all - -Will now deny to dance? she that makes dainty, she, - -I'll swear, hath corns. Am I come near you now? - -You are welcome, gentlemen! I have seen the day, - -That I have worn a visor, and could tell - -A whispering tale in a fair lady's ear, - -Such as would please:—'tis gone, 'tis gone, 'tis gone. - -You are welcome, gentlemen!—Come, musicians, play. - - - -A hall! a hall! give room, and foot it, girls. [Music plays, and they dance. - -More light, ye knaves! and turn the tables up, - -And quench the fire, the room is grown too hot.— - -Ah! sirrah, this unlook'd-for sport comes well. - -Nay, sit, nay, sit, good cousin Capulet, - -For you and I are past our dancing days: - -How long is't now, since last yourself and I - -Were in a mask? - -2 Cap. By'r lady, thirty years. - -1 Cap. What, man! 'tis not so much, 'tis not so much: - -'Tis since the nuptial of Lucentio, - -Come Pentecost as quickly as it will, - -Some five and twenty years; and then we mask'd. - -2 Cap. 'Tis more, 'tis more: his son is elder, sir; - -His son is thirty. - -1 Cap. Will you tell me that? - -His son was but a ward two years ago. - -Rom. What lady is that, which doth enrich the hand - -Of yonder knight? - -Serv. I know not, sir. - -Rom. O! she doth teach the torches to burn bright. - -Her beauty hangs upon the cheek of night - -Like a rich jewel in an Æthiop's ear; - -Beauty too rich for use, for earth too dear! - -So shows a snowy dove trooping with crows, - -As yonder lady o'er her fellows shows. - -The measure done, I'll watch her place of stand, - -And, touching hers, make blessed my rude hand. - -Did my heart love till now? forswear it, sight! - -I never saw true beauty till this night. - -Tyb. This, by his voice, should be a Montague.— - -Fetch me my rapier, boy.—What! dares the slave - -Come hither, cover'd with an antic face, - -To fleer and scorn at our solemnity? - -Now, by the stock and honour of my kin, - -To strike him dead I hold it not a sin. - -1 Cap. Why, how now, kinsman! wherefore storm you so? - -Tyb. Uncle, this is a Montague, our foe; - -A villain, that is hither come in spite, - -To scorn at our solemnity this night. - -1 Cap. Young Romeo is it? - -Tyb. 'Tis he, that villain Romeo. - -1 Cap. Content thee, gentle coz, let him alone, - -He bears him like a portly gentleman; - -And, to say truth, Verona brags of him, - -To be a virtuous and well-govern'd youth. - -I would not for the wealth of all this town, - -Here, in my house, do him disparagement; - -Therefore, be patient, take no note of him: - -It is my will; the which if thou respect, - -Show a fair presence, and put off these frowns, - -An ill-beseeming semblance for a feast. - -Tyb. It fits, when such a villain is a guest. - -I'll not endure him. - -1 Cap. He shall be endur'd: - -What! goodman boy!—I say, he shall;—go to;— - -Am I the master here, or you? go to. - -You'll not endure him! God shall mend my soul— - -You'll make a mutiny among my guests. - -You will set cock-a-hoop! you'll be the man! - -Tyb. Why, uncle, 'tis a shame. - -1 Cap. Go to, go to; - -You are a saucy boy.—Is't so, indeed?— - -This trick may chance to scath you;—I know what. - -You must contrary me! marry, 'tis time— - -Well said, my hearts!—You are a princox; go:— - -Be quiet, or—More light, more light!—for shame! - -I'll make you quiet;—What!—Cheerly, my hearts! - -Tyb. Patience perforce with wilful choler meeting, - -Makes my flesh tremble in their different greeting. - -I will withdraw: but this intrusion shall, - - - -Now seeming sweet, convert to bitter gall. [Exit. - -Rom. If I profane with my unworthiest hand [To Juliet. - -⁠This holy shrine, the gentle fine is this,— - -My lips, two blushing pilgrims, ready stand - -⁠To smooth that rough touch with a tender kiss. - -Jul. Good pilgrim, you do wrong your hand too much, - -⁠Which mannerly devotion shows in this; - -For saints have hands that pilgrims' hands do touch, - -⁠And palm to palm is holy palmers' kiss. - -Rom. Have not saints lips, and holy palmers too? - -Jul. Ay, pilgrim, lips that they must use in prayer. - -Rom. O! then, dear saint, let lips do what hands do; - -They pray, grant thou, lest faith turn to despair. - -Jul. Saints do not move, though grant for prayers' sake. - -Rom. Then move not, while my prayer's effect I take. - - - -Thus from my lips, by thine, my sin is purg'd. [Kissing her. - -Jul. Then have my lips the sin that they have took. - -Rom. Sin from my lips? O, trespass sweetly urg'd! - -Give me my sin again. - -Jul. You kiss by the book. - -Nurse. Madam, your mother craves a word with you. - -Rom. What is her mother? - -Nurse. Marry, bachelor, - -Her mother is the lady of the house, - -And a good lady, and a wise, and virtuous. - -I nurs'd her daughter, that you talk'd withal; - -I tell you—he that can lay hold of her - -Shall have the chinks. - - - -​Rom. Is she a Capulet? - -O, dear account! my life is my foe's debt. - -Ben. Away, begone: the sport is at the best. - -Rom. Ay, so I fear; the more is my unrest. - -1 Cap. Nay, gentlemen, prepare not to be gone; - -We have a trifling foolish banquet towards.— - -Is it e'en so? Why then, I thank you all; - -I thank you, honest gentlemen; good night:— - -More torches here!—Come on, then let's to bed. - -Ah, sirrah, by my fay, it waxes late; - - - -I'll to my rest. [Exeunt all but Juliet and Nurse. - -Jul. Come hither, nurse. What is yond' gentleman? - -Nurse. The son and heir of old Tiberio. - -Jul. What's he, that now is going out of door? - -Nurse. Marry, that, I think, be young Petruchio. - -Jul. What's he, that follows here, that would not dance? - -Nurse. I know not. - -Jul. Go, ask his name.—If he be married, - -My grave is like to be my wedding bed. - -Nurse. His name is Romeo, and a Montague; - -The only son of your great enemy. - -Jul. My only love sprung from my only hate! - -Too early seen unknown, and known too late! - -Prodigious birth of love it is to me, - -That I must love a loathed enemy. - -Nurse. What's this? what's this? - -Jul. A rhyme I learn'd even now - -Of one I danc'd withal. [One calls within, Juliet! - -Nurse. Anon, anon: - -Come, let's away; the strangers all are gone. [Exeunt. - - - - - -Enter Chorus. - - - -Now old desire doth in his death-bed lie, - -And young affection gapes to be his heir: - -That fair, for which love groan'd for, and would die, - -With tender Juliet match'd is now not fair. - -Now Romeo is belov'd, and loves again, - -Alike bewitched by the charm of looks; - -But to his foe suppos'd he must complain, - -And she steal love's sweet bait from fearful hooks: - -Being held a foe, he may not have access - -To breathe such vows as lovers use to swear; - -And she as much in love, her means much less - -To meet her new-beloved anywhere: - -But passion lends them power, time, means, to meet, - - - -Tempering extremities with extreme sweet. [Exit. - - - - - -An image should appear at this position in the text. - -If you are able to provide it, see Wikisource:Image guidelines and Help:Adding images for guidance. - - - - - -About this digital edition - - -This e-book comes from the online library Wikisource[1]. This multilingual digital library, built by volunteers, is committed to developing a free accessible collection of publications of every kind: novels, poems, magazines, letters... - -We distribute our books for free, starting from works not copyrighted or published under a free license. You are free to use our e-books for any purpose (including commercial exploitation), under the terms of the Creative Commons Attribution-ShareAlike 3.0 Unported[2] license or, at your choice, those of the GNU FDL[3]. - -Wikisource is constantly looking for new members. During the realization of this book, it's possible that we made some errors. You can report them at this page[4]. - -The following users contributed to this book: - -Angelprincess72 - -Djr13 - -ThomasBot - -BirgitteSB - -Mpaa - -Beleg Tâl - -Einstein95 - -Kathleen.wright5 - -EncycloPetey - -Dariyman - - - - - -* * * - - - -↑ http://wikisource.org - -↑ http://www.creativecommons.org/licenses/by-sa/3.0 - -↑ http://www.gnu.org/copyleft/fdl.html - -↑ http://wikisource.org/wiki/Wikisource:Scriptorium - - - - - diff --git a/tests/fixtures/case_meetup.ics b/tests/fixtures/case_meetup.ics deleted file mode 100644 index 67f42cf3..00000000 --- a/tests/fixtures/case_meetup.ics +++ /dev/null @@ -1,78 +0,0 @@ -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Meetup//RemoteApi//EN -CALSCALE:GREGORIAN -METHOD:PUBLISH -X-ORIGINAL-URL:http://www.meetup.com/DevOpsDC/events/ical/DevOpsDC/ -X-WR-CALNAME:Events - DevOpsDC -BEGIN:VTIMEZONE -TZID:America/New_York -TZURL:http://tzurl.org/zoneinfo-outlook/America/New_York -X-LIC-LOCATION:America/New_York -BEGIN:DAYLIGHT -TZOFFSETFROM:-0500 -TZOFFSETTO:-0400 -TZNAME:EDT -DTSTART:19700308T020000 -RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU -END:DAYLIGHT -BEGIN:STANDARD -TZOFFSETFROM:-0400 -TZOFFSETTO:-0500 -TZNAME:EST -DTSTART:19701101T020000 -RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU -END:STANDARD -END:VTIMEZONE -BEGIN:VEVENT -DTSTAMP:20120605T003759Z -DTSTART;TZID=America/New_York:20120712T183000 -DTEND;TZID=America/New_York:20120712T213000 -STATUS:CONFIRMED -SUMMARY:DevOps DC Meetup -DESCRIPTION:DevOpsDC\nThursday\, July 12 at 6:30 PM\n\nThis will be a joi - nt meetup / hack night with the DC jQuery Users Group. The idea behind - the hack night: Small teams consisting of at least 1 member...\n\nDeta - ils: http://www.meetup.com/DevOpsDC/events/47635522/ -CLASS:PUBLIC -CREATED:20120111T120339Z -GEO:38.90;-77.01 -LOCATION:Fathom Creative\, Inc. (1333 14th Street Northwest\, Washington - D.C.\, DC 20005) -URL:http://www.meetup.com/DevOpsDC/events/47635522/ -LAST-MODIFIED:20120522T174406Z -UID:event_qtkfrcyqkbnb@meetup.com -END:VEVENT -BEGIN:VEVENT -DTSTAMP:20120605T003759Z -DTSTART;TZID=America/New_York:20120911T183000 -DTEND;TZID=America/New_York:20120911T213000 -STATUS:CONFIRMED -SUMMARY:DevOps DC Meetup -DESCRIPTION:DevOpsDC\nTuesday\, September 11 at 6:30 PM\n\n \n\nDetails: - http://www.meetup.com/DevOpsDC/events/47635532/ -CLASS:PUBLIC -CREATED:20120111T120352Z -GEO:38.90;-77.01 -LOCATION:CustomInk\, LLC (7902 Westpark Drive\, McLean\, VA 22102) -URL:http://www.meetup.com/DevOpsDC/events/47635532/ -LAST-MODIFIED:20120316T202210Z -UID:event_qtkfrcyqmbpb@meetup.com -END:VEVENT -BEGIN:VEVENT -DTSTAMP:20120605T003759Z -DTSTART;TZID=America/New_York:20121113T183000 -DTEND;TZID=America/New_York:20121113T213000 -STATUS:CONFIRMED -SUMMARY:DevOps DC Meetup -DESCRIPTION:DevOpsDC\nTuesday\, November 13 at 6:30 PM\n\n \n\nDetails: h - ttp://www.meetup.com/DevOpsDC/events/47635552/ -CLASS:PUBLIC -CREATED:20120111T120402Z -GEO:38.90;-77.01 -LOCATION:CustomInk\, LLC (7902 Westpark Drive\, McLean\, VA 22102) -URL:http://www.meetup.com/DevOpsDC/events/47635552/ -LAST-MODIFIED:20120316T202210Z -UID:event_qtkfrcyqpbrb@meetup.com -END:VEVENT -END:VCALENDAR diff --git a/tests/fixtures/encoding.ics b/tests/fixtures/encoding.ics deleted file mode 100644 index 5a0047eb..00000000 --- a/tests/fixtures/encoding.ics +++ /dev/null @@ -1,16 +0,0 @@ -BEGIN:VCALENDAR -PRODID:-//Plönë.org//NONSGML plone.app.event//EN -VERSION:2.0 -X-WR-CALNAME:äöü ÄÖÜ € -X-WR-CALDESC:test non ascii: äöü ÄÖÜ € -X-WR-RELCALID:12345 -BEGIN:VEVENT -DTSTART:20101010T100000Z -DTEND:20101010T120000Z -CREATED:20101010T100000Z -UID:123456 -SUMMARY:Non-ASCII Test: ÄÖÜ äöü € -DESCRIPTION:icalendar should be able to handle non-ascii: €äüöÄÜÖ. -LOCATION:Tribstrül -END:VEVENT -END:VCALENDAR diff --git a/tests/fixtures/groupscheduled.ics b/tests/fixtures/groupscheduled.ics deleted file mode 100644 index 7fc4b6f6..00000000 --- a/tests/fixtures/groupscheduled.ics +++ /dev/null @@ -1,36 +0,0 @@ -BEGIN:VCALENDAR -PRODID:-//RDU Software//NONSGML HandCal//EN -VERSION:2.0 -BEGIN:VTIMEZONE -TZID:US-Eastern -BEGIN:STANDARD -DTSTART:19981025T020000 -RDATE:19981025T020000 -TZOFFSETFROM:-0400 -TZOFFSETTO:-0500 -TZNAME:EST -END:STANDARD -BEGIN:DAYLIGHT -DTSTART:19990404T020000 -RDATE:19990404T020000 -TZOFFSETFROM:-0500 -TZOFFSETTO:-0400 -TZNAME:EDT -END:DAYLIGHT -END:VTIMEZONE -BEGIN:VEVENT -DTSTAMP:19980309T231000Z -UID:guid-1.host1.com -ORGANIZER;ROLE=CHAIR:MAILTO:mrbig@host.com -ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT;CUTYPE=GROUP: -MAILTO:employee-A@host.com -DESCRIPTION:Project XYZ Review Meeting -CATEGORIES:MEETING -CLASS:PUBLIC -CREATED:19980309T130000Z -SUMMARY:XYZ Project Review -DTSTART;TZID=US-Eastern:19980312T083000 -DTEND;TZID=US-Eastern:19980312T093000 -LOCATION:1CP Conference Room 4350 -END:VEVENT -END:VCALENDAR diff --git a/tests/fixtures/multiple.ics b/tests/fixtures/multiple.ics deleted file mode 100644 index dbbde27b..00000000 --- a/tests/fixtures/multiple.ics +++ /dev/null @@ -1,80 +0,0 @@ -BEGIN:VCALENDAR -VERSION - - :2.0 -PRODID - - :-//Mozilla.org/NONSGML Mozilla Calendar V1.0//EN -METHOD - - :PUBLISH -BEGIN:VEVENT -UID - - :956630271 -SUMMARY - - :Christmas Day -CLASS - - :PUBLIC -X-MOZILLA-ALARM-DEFAULT-UNITS - - :minutes -X-MOZILLA-ALARM-DEFAULT-LENGTH - - :15 -X-MOZILLA-RECUR-DEFAULT-UNITS - - :weeks -X-MOZILLA-RECUR-DEFAULT-INTERVAL - - :1 -DTSTART - - ;VALUE=DATE - :20031225 -DTEND - - ;VALUE=DATE - :20031226 -DTSTAMP - - :20020430T114937Z -END:VEVENT -END:VCALENDAR -BEGIN:VCALENDAR -VERSION - :2.0 -PRODID - :-//Mozilla.org/NONSGML Mozilla Calendar V1.0//EN -METHOD - :PUBLISH -BEGIN:VEVENT -UID - :911737808 -SUMMARY - :Boxing Day -CLASS - :PUBLIC -X-MOZILLA-ALARM-DEFAULT-UNITS - :minutes -X-MOZILLA-ALARM-DEFAULT-LENGTH - :15 -X-MOZILLA-RECUR-DEFAULT-UNITS - :weeks -X-MOZILLA-RECUR-DEFAULT-INTERVAL - :1 -DTSTART - ;VALUE=DATE - :20030501 -DTSTAMP - :20020430T114937Z -END:VEVENT -BEGIN:VEVENT -UID - :wh4t3v3r -DTSTART;VALUE=DATE:20031225 -SUMMARY:Christmas again! -END:VEVENT -END:VCALENDAR diff --git a/tests/fixtures/recurrence.ics b/tests/fixtures/recurrence.ics deleted file mode 100644 index 6971bb27..00000000 --- a/tests/fixtures/recurrence.ics +++ /dev/null @@ -1,12 +0,0 @@ -BEGIN:VCALENDAR -METHOD:Request -PRODID:-//My product//mxm.dk/ -VERSION:2.0 -BEGIN:VEVENT -DTSTART:19960401T010000 -DTEND:19960401T020000 -RRULE:FREQ=DAILY;COUNT=100 -EXDATE:19960402T010000Z,19960403T010000Z,19960404T010000Z -SUMMARY:A recurring event with exdates -END:VEVENT -END:VCALENDAR diff --git a/tests/fixtures/small.ics b/tests/fixtures/small.ics deleted file mode 100644 index 8b0a193d..00000000 --- a/tests/fixtures/small.ics +++ /dev/null @@ -1,25 +0,0 @@ -BEGIN:VCALENDAR -METHOD:Request -PRODID:-//My product//mxm.dk/ -VERSION:2.0 -BEGIN:VEVENT -DESCRIPTION:This is a very long description that will be folded This is a - very long description that will be folded This is a very long description - that will be folded This is a very long description that will be folded Th - is is a very long description that will be folded This is a very long desc - ription that will be folded This is a very long description that will be f - olded This is a very long description that will be folded This is a very l - ong description that will be folded This is a very long description that w - ill be folded -PARTICIPANT;CN=Max M:MAILTO:maxm@mxm.dk -DTEND:20050107T160000 -DTSTART:20050107T120000 -SUMMARY:A second event -END:VEVENT -BEGIN:VEVENT -DTEND:20050108T235900 -DTSTART:20050108T230000 -SUMMARY:A single event -UID:42 -END:VEVENT -END:VCALENDAR diff --git a/tests/fixtures/spaces.ics b/tests/fixtures/spaces.ics deleted file mode 100644 index 39b1fc70..00000000 --- a/tests/fixtures/spaces.ics +++ /dev/null @@ -1,39 +0,0 @@ -BEGIN:VCALENDAR -PRODID:-//Nextcloud calendar v1.7.2 -VERSION:2.0 -CALSCALE:GREGORIAN -BEGIN:VEVENT -CREATED:20200223T183124 -DTSTAMP:20200223T183124 -LAST-MODIFIED:20200223T183124 -UID:JCGUPMSIMHOT80Q0CN3NV -SUMMARY:Spaces -CLASS:PUBLIC -DESCRIPTION:starting with 78 spaces\n - starting with 79 spaces\n\n\n\n - \n\n\n an - d\n also\n a\n lot\n of\n tabs\n even \n tra - iling \n ones -STATUS:CONFIRMED -DTSTART;TZID=Europe/Berlin:20200221T180000 -DTEND;TZID=Europe/Berlin:20200221T190000 -END:VEVENT -BEGIN:VTIMEZONE -TZID:Europe/Berlin -X-LIC-LOCATION:Europe/Berlin -BEGIN:DAYLIGHT -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -TZNAME:CEST -DTSTART:19700329T020000 -RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU -END:DAYLIGHT -BEGIN:STANDARD -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -TZNAME:CET -DTSTART:19701025T030000 -RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU -END:STANDARD -END:VTIMEZONE -END:VCALENDAR \ No newline at end of file diff --git a/tests/fixtures/time.ics b/tests/fixtures/time.ics deleted file mode 100644 index d730a4c4..00000000 --- a/tests/fixtures/time.ics +++ /dev/null @@ -1,3 +0,0 @@ -BEGIN:VCALENDAR -X-SOMETIME;VALUE=TIME:172010 -END:VCALENDAR diff --git a/tests/fixtures/timezoned.ics b/tests/fixtures/timezoned.ics deleted file mode 100644 index 5878b723..00000000 --- a/tests/fixtures/timezoned.ics +++ /dev/null @@ -1,36 +0,0 @@ -BEGIN:VCALENDAR -PRODID:-//Plone.org//NONSGML plone.app.event//EN -VERSION:2.0 -X-WR-CALNAME:test create calendar -X-WR-CALDESC:icalendar test -X-WR-RELCALID:12345 -X-WR-TIMEZONE:Europe/Vienna -BEGIN:VTIMEZONE -TZID:Europe/Vienna -X-LIC-LOCATION:Europe/Vienna -BEGIN:DAYLIGHT -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -TZNAME:CEST -DTSTART:19700329T020000 -RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU -END:DAYLIGHT -BEGIN:STANDARD -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -TZNAME:CET -DTSTART:19701025T030000 -RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU -END:STANDARD -END:VTIMEZONE -BEGIN:VEVENT -DTSTART;TZID=Europe/Vienna:20120213T100000 -DTEND;TZID=Europe/Vienna:20120217T180000 -DTSTAMP:20101010T091010Z -CREATED:20101010T091010Z -UID:123456 -SUMMARY:artsprint 2012 -DESCRIPTION:sprinting at the artsprint -LOCATION:aka bild, wien -END:VEVENT -END:VCALENDAR diff --git a/tests/fixtures/utf-8-emoji.ics b/tests/fixtures/utf-8-emoji.ics deleted file mode 100644 index 06fd8ba8..00000000 --- a/tests/fixtures/utf-8-emoji.ics +++ /dev/null @@ -1,6823 +0,0 @@ -BEGIN:VCALENDAR -VERSION:2.0 -CALSCALE:GREGORIAN -PRODID:-//SabreDAV//SabreDAV//EN -X-WR-CALNAME:⟦UTF-8⟧ Test 🎉 -X-APPLE-CALENDAR-COLOR:#317CCC -BEGIN:VTIMEZONE -TZID:Europe/Berlin -X-LIC-LOCATION:Europe/Berlin -BEGIN:DAYLIGHT -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -TZNAME:CEST -DTSTART:19700329T020000 -RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU -END:DAYLIGHT -BEGIN:STANDARD -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -TZNAME:CET -DTSTART:19701025T030000 -RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU -END:STANDARD -END:VTIMEZONE -BEGIN:VEVENT -CREATED:20200221T214401 -DTSTAMP:20200221T214401 -LAST-MODIFIED:20200221T214401 -UID:WYVTMOHYEWH3NK8SACQP -SUMMARY:Emoji Default Style Values\, v13.0 © Unicode®\, Inc. -CLASS:PUBLIC -DESCRIPTION:Emoji Default Style Values\, v13.0\nThis text file provides a l - isting of characters for testing the display of emoji characters.\n• “ - -vs” indicates that emoji presentation selectors are not present\, and a - character has Emoji_Presentation=False.\n• “+es” indicates that emo - ji presentation selectors are present\, and a character has Emoji_Presenta - tion=False.\n• “+ts” indicates that text presentation selectors are - present\, and a character has Emoji_Presentation=False.\nFor more informat - ion on presentation style\, see UTS #51.\n\nShould all be colorful & monos - pace\, except that those marked with “text+ts” should be monochrome\, - and those with “text-vs” may vary by platform.\n\n# © 2019 Unicode®\ - , Inc.\n# Unicode and the Unicode Logo are registered trademarks of Unicod - e\, Inc. in the U.S. and other countries.\n# For terms of use\, see http:/ - /www.unicode.org/terms_of_use.html\n\ntext+ts\n〰︎ ‼︎ ⁉︎ *︎ - ⃣ #︎⃣ 〽︎ ©︎ ®︎ ↔︎ ↕︎ ↖︎ ↗︎ ↘︎ ↙︎ - ↩︎ ↪︎ ⌨︎ ⏏︎ ⏭︎ ⏮︎ ⏯︎\n⏱︎ ⏲︎ ⏸︎ ⏹ - ︎ ⏺︎ ▪︎ ▫︎ ▶︎ ◀︎ ◻︎ ◼︎ ☀︎ ☁︎ ☂︎ - ☃︎ ☄︎ ☎︎ ☑︎ ☘︎ ☝︎ ☠︎\n☢︎ ☣︎ ☦︎ ☪ - ︎ ☮︎ ☯︎ ☸︎ ☹︎ ☺︎ ♀︎ ♂︎ ♟︎ ♠︎ ♣︎ - ♥︎ ♦︎ ♨︎ ♻︎ ♾︎ ⚒︎ ⚔︎\n⚕︎ ⚖︎ ⚗︎ ⚙ - ︎ ⚛︎ ⚜︎ ⚠︎ ⚧︎ ⚰︎ ⚱︎ ⛈︎ ⛏︎ ⛑︎ ⛓︎ - ⛩︎ ⛰︎ ⛱︎ ⛴︎ ⛷︎ ⛸︎ ⛹︎\n✂︎ ✈︎ ✉︎ ✌ - ︎ ✍︎ ✏︎ ✒︎ ✔︎ ✖︎ ✝︎ ✡︎ ✳︎ ✴︎ ❄︎ - ❇︎ ❣︎ ❤︎ ➡︎ ⤴︎ ⤵︎ ⬅︎\n⬆︎ ⬇︎ 🌡︎ - 🌤︎ 🌥︎ 🌦︎ 🌧︎ 🌨︎ 🌩︎ 🌪︎ 🌫︎ 🌬︎ - 🌶︎ 🍽︎ 🎖︎ 🎗︎ 🎙︎ 🎚︎ 🎛︎ 🎞︎ 🎟︎\n - 🏋︎ 🏌︎ 🏍︎ 🏎︎ 🏔︎ 🏕︎ 🏖︎ 🏗︎ 🏘︎ - ��︎ 🏚︎ 🏛︎ 🏜︎ 🏝︎ 🏞︎ 🏟︎ 🏳︎ 🏵︎ - 🏷︎ 🐿︎ 👁︎\n📽︎ 🕉︎ 🕊︎ 🕯︎ 🕰︎ 🕳︎ - 🕴︎ 🕵︎ 🕶︎ 🕷︎ 🕸︎ 🕹︎ 🖇︎ 🖊︎ 🖋︎ - 🖌︎ 🖍︎ 🖐︎ 🖥︎ 🖨︎ 🖱︎\n🖲︎ 🖼︎ 🗂︎ - 🗃︎ ��︎ 🗑︎ 🗒︎ 🗓︎ 🗜︎ 🗝︎ 🗞︎ 🗡︎ - 🗣︎ 🗨︎ 🗯︎ 🗳︎ 🗺︎ 🛋︎ 🛍︎ 🛎︎ 🛏︎\n - 🛠︎ 🛡︎ 🛢︎ 🛣︎ 🛤︎ 🛥︎ 🛩︎ 🛰︎ 🛳︎ 0 - ︎⃣ 1︎⃣ 2︎⃣ 3︎⃣ 4︎⃣ 5︎⃣ 6︎⃣ 7︎⃣ 8︎⃣ 9 - ︎⃣ 🅰︎ 🅱︎\nℹ︎ Ⓜ︎ 🅾︎ 🅿︎ ™︎ 🈂︎ 🈷 - ︎ ㊗︎ ㊙︎\n\ntext-vs\n☺ ☹ ☠ ❣ ❤ 🕳 🗨 🗯 🖐 ✌ - ☝ ✍ �� 🕵 🕴 ⛷ 🏌 ⛹ 🏋 🗣 🐿\n🕊 🕷 🕸 🏵 - ☘ 🌶 🍽 🗺 🏔 ⛰ 🏕 🏖 🏜 🏝 🏞 🏟 🏛 �� 🏘 - 🏚 ⛩\n🏙 ♨ 🏎 🏍 🛣 🛤 🛢 🛳 ⛴ 🛥 ✈ 🛩 🛰 - 🛎 ⏱ ⏲ 🕰 🌡 ☀ ☁ ⛈\n🌤 🌥 🌦 🌧 🌨 🌩 🌪 - 🌫 🌬 ☂ ⛱ ❄ ☃ ☄ 🎗 🎟 🎖 ⛸ 🕹 ♠ ♥\n♦ ♣ ♟ - 🖼 🕶 🛍 ⛑ 🎙 🎚 🎛 ☎ 🖥 🖨 ⌨ 🖱 🖲 🎞 📽 - 🕯 🗞 🏷\n✉ 🗳 ✏ ✒ 🖋 🖊 🖌 🖍 🗂 🗒 🗓 🖇 - ✂ 🗃 🗄 🗑 🗝 ⛏ ⚒ 🛠 🗡\n⚔ 🛡 ⚙ 🗜 ⚖ ⛓ ⚗ - 🛏 🛋 ⚰ ⚱ ⚠ ☢ ☣ ⬆ ↗ ➡ ↘ ⬇ ↙ ⬅\n↖ ↕ ↔ ↩ - ↪ ⤴ ⤵ ⚛ 🕉 ✡ ☸ ☯ ✝ ☦ ☪ ☮ ▶ ⏭ ⏯ ◀ ⏮\n⏸ - ⏹ ⏺ ⏏ ♀ ♂ ⚧ ✖ ♾ ‼ ⁉ 〰 ⚕ ♻ ⚜ ☑ ✔ 〽 ✳ - ✴ ❇\n© ® ™ #⃣ *⃣ 0⃣ 1⃣ 2⃣ 3⃣ 4⃣ 5⃣ 6⃣ 7⃣ 8⃣ - 9⃣ 🅰 🅱 ℹ Ⓜ 🅾 🅿\n🈂 🈷 ㊗ ㊙ ◼ ◻ ▪ ▫ 🏳\n - \ntext+es\n☺️ ☹️ ☠️ ❣️ ❤️ 🕳️ 🗨️ 🗯️ 🖐 - ️ ✌️ ☝️ ✍️ 👁️ 🕵️ 🕴️ ⛷️ 🏌️ ⛹️ - 🏋️ 🗣️ 🐿️\n🕊️ 🕷️ 🕸️ 🏵️ ☘️ 🌶️ - 🍽️ 🗺️ 🏔️ ⛰️ 🏕️ 🏖️ 🏜️ 🏝️ 🏞️ - 🏟️ 🏛️ 🏗️ 🏘️ 🏚️ ⛩️\n🏙️ ♨️ 🏎️ - 🏍️ 🛣️ 🛤️ 🛢️ 🛳️ ⛴️ 🛥️ ✈️ 🛩️ 🛰 - ️ 🛎️ ⏱️ ⏲️ 🕰️ 🌡️ ☀️ ☁️ ⛈️\n🌤️ - 🌥️ 🌦️ 🌧️ 🌨️ 🌩️ 🌪️ 🌫️ 🌬️ ☂️ ⛱ - ️ ❄️ ☃️ ☄️ 🎗️ 🎟️ 🎖️ ⛸️ 🕹️ ♠️ ♥ - ️\n♦️ ♣️ ♟️ 🖼️ 🕶️ 🛍️ ⛑️ 🎙️ 🎚️ - 🎛️ ☎️ 🖥️ 🖨️ ⌨️ 🖱️ 🖲️ 🎞️ 📽️ 🕯 - ️ 🗞️ 🏷️\n✉️ 🗳️ ✏️ ✒️ 🖋️ 🖊️ 🖌️ - 🖍️ 🗂️ 🗒️ 🗓️ 🖇️ ✂️ 🗃️ 🗄️ 🗑️ - 🗝️ ⛏️ ⚒️ 🛠️ 🗡️\n⚔️ 🛡️ ⚙️ 🗜️ ⚖ - ️ ⛓️ ⚗️ 🛏️ 🛋️ ⚰️ ⚱️ ⚠️ ☢️ ☣️ ⬆ - ️ ↗️ ➡️ ↘️ ⬇️ ↙️ ⬅️\n↖️ ↕️ ↔️ ↩️ - ↪️ ⤴️ ⤵️ ⚛️ 🕉️ ✡️ ☸️ ☯️ ✝️ ☦️ - ☪️ ☮️ ▶️ ⏭️ ⏯️ ◀️ ⏮️\n⏸️ ⏹️ ⏺️ ⏏ - ️ ♀️ ♂️ ⚧️ ✖️ ♾️ ‼️ ⁉️ 〰️ ⚕️ ♻️ - ⚜️ ☑️ ✔️ 〽️ ✳️ ✴️ ❇️\n©️ ®️ ™️ #️ - ⃣ *️⃣ 0️⃣ 1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣ 6️⃣ 7️ - ⃣ 8️⃣ 9️⃣ 🅰️ 🅱️ ℹ️ Ⓜ️ 🅾️ 🅿️\n🈂️ - 🈷️ ㊗️ ㊙️ ◼️ ◻️ ▪️ ▫️ 🏳️\n\nemoji cps\n - 😀 😃 😄 😁 😆 😅 🤣 😂 🙂 🙃 😉 😊 😇 🥰 😍 - 🤩 😘 😗 😚 😙 🥲\n😋 😛 😜 🤪 😝 🤑 🤗 🤭 - 🤫 🤔 �� 🤨 😐 😑 😶 😏 😒 🙄 😬 🤥 😌\n😔 - 😪 🤤 😴 😷 🤒 🤕 🤢 🤮 🤧 🥵 🥶 🥴 😵 🤯 🤠 - 🥳 🥸 😎 🤓 🧐\n😕 😟 🙁 😮 😯 😲 😳 🥺 😦 - 😧 😨 😰 😥 😢 😭 😱 😖 😣 😞 😓 😩\n😫 🥱 - 😤 😡 😠 🤬 😈 👿 💀 💩 🤡 👹 👺 👻 👽 👾 🤖 - 😺 😸 😹 😻\n😼 😽 🙀 😿 😾 🙈 🙉 🙊 💋 💌 - 💘 💝 💖 💗 💓 💞 💕 💟 💔 🧡 💛\n💚 💙 💜 - 🤎 🖤 🤍 💯 💢 💥 💫 💦 💨 💣 💬 💭 💤 👋 🤚 - ✋ 🖖 👌\n🤌 🤏 🤞 🤟 🤘 🤙 👈 👉 👆 🖕 👇 - 👍 👎 ✊ 👊 🤛 🤜 👏 🙌 👐 🤲\n🤝 🙏 💅 🤳 💪 - 🦾 🦿 🦵 🦶 👂 🦻 👃 🧠 🫀 🫁 🦷 🦴 👀 👅 - 👄 👶\n🧒 👦 👧 🧑 👱 👨 🧔 👩 🧓 👴 👵 🙍 - 🙎 🙅 🙆 �� 🙋 🧏 🙇 🤦 🤷\n👮 💂 🥷 👷 🤴 - 👸 👳 👲 🧕 🤵 👰 🤰 🤱 👼 🎅 🤶 🦸 🦹 🧙 🧚 - 🧛\n🧜 🧝 🧞 🧟 💆 💇 🚶 🧍 🧎 🏃 💃 🕺 👯 - 🧖 🧗 🤺 🏇 🏂 🏄 🚣 🏊\n🚴 �� 🤸 🤼 🤽 🤾 - 🤹 🧘 🛀 🛌 👭 👫 👬 💏 💑 👪 👤 👥 🫂 👣 🦰 - \n🦱 🦳 🦲 🐵 🐒 🦍 🦧 🐶 🐕 🦮 🐩 🐺 🦊 🦝 - 🐱 🐈 🦁 🐯 🐅 🐆 🐴\n🐎 🦄 🦓 🦌 🦬 🐮 🐂 - 🐃 🐄 🐷 🐖 🐗 🐽 🐏 🐑 🐐 🐪 🐫 🦙 🦒 🐘\n - 🦣 🦏 🦛 🐭 🐁 🐀 🐹 🐰 🐇 🦫 🦔 🦇 �� 🐨 - 🐼 🦥 🦦 🦨 🦘 🦡 🐾\n🦃 🐔 🐓 🐣 🐤 🐥 🐦 - 🐧 🦅 🦆 🦢 🦉 🦤 🪶 🦩 🦚 🦜 🐸 🐊 🐢 🦎\n - 🐍 🐲 🐉 🦕 🦖 🐳 🐋 🐬 🦭 🐟 🐠 🐡 🦈 🐙 🐚 - 🐌 🦋 🐛 🐜 🐝 🪲\n🐞 🦗 🪳 🦂 🦟 🪰 🪱 🦠 - 💐 🌸 💮 🌹 🥀 🌺 🌻 🌼 🌷 🌱 🪴 🌲 🌳\n🌴 - 🌵 🌾 🌿 🍀 🍁 🍂 🍃 🍇 🍈 🍉 🍊 🍋 🍌 🍍 🥭 - 🍎 🍏 🍐 🍑 🍒\n🍓 🫐 🥝 🍅 🫒 🥥 🥑 🍆 🥔 - 🥕 🌽 🫑 🥒 🥬 🥦 🧄 🧅 🍄 🥜 🌰 🍞\n🥐 🥖 - 🫓 🥨 🥯 🥞 🧇 🧀 🍖 �� 🥩 🥓 🍔 🍟 🍕 🌭 - 🥪 🌮 🌯 🫔 🥙\n🧆 🥚 🍳 🥘 🍲 🫕 🥣 🥗 🍿 - 🧈 🧂 🥫 🍱 🍘 🍙 🍚 🍛 🍜 🍝 🍠 🍢\n🍣 🍤 - 🍥 🥮 🍡 🥟 🥠 🥡 🦀 🦞 🦐 🦑 🦪 🍦 🍧 🍨 🍩 - 🍪 🎂 🍰 🧁\n🥧 🍫 🍬 🍭 🍮 🍯 🍼 🥛 ☕ 🫖 - 🍵 🍶 🍾 🍷 🍸 🍹 🍺 🍻 🥂 🥃 🥤\n🧋 🧃 🧉 - 🧊 🥢 🍴 🥄 🔪 🏺 🌍 🌎 🌏 🌐 🗾 🧭 🌋 🗻 🧱 - 🪨 🪵 🛖\n🏠 🏡 🏢 🏣 🏤 🏥 🏦 🏨 🏩 🏪 🏫 - 🏬 🏭 🏯 🏰 💒 🗼 🗽 ⛪ 🕌 🛕\n🕍 🕋 ⛲ ⛺ 🌁 - 🌃 🌄 🌅 🌆 🌇 🌉 🎠 🎡 🎢 💈 🎪 🚂 🚃 🚄 🚅 - 🚆\n🚇 🚈 🚉 🚊 🚝 🚞 🚋 🚌 🚍 🚎 🚐 �� 🚒 - 🚓 🚔 🚕 🚖 🚗 🚘 🚙 🛻\n🚚 🚛 🚜 🛵 🦽 🦼 - 🛺 🚲 🛴 🛹 🛼 🚏 ⛽ 🚨 🚥 �� 🛑 🚧 ⚓ ⛵ 🛶\ - n🚤 🚢 🛫 🛬 🪂 💺 🚁 🚟 🚠 🚡 🚀 🛸 🧳 ⌛ ⏳ - ⌚ ⏰ 🕛 🕧 🕐 🕜\n🕑 🕝 🕒 🕞 🕓 🕟 🕔 🕠 🕕 - 🕡 🕖 🕢 🕗 🕣 🕘 🕤 🕙 🕥 🕚 🕦 🌑\n🌒 🌓 - 🌔 �� 🌖 🌗 🌘 🌙 🌚 🌛 🌜 🌝 🌞 🪐 ⭐ 🌟 - 🌠 🌌 ⛅ 🌀 🌈\n🌂 ☔ ⚡ ⛄ 🔥 💧 🌊 🎃 🎄 🎆 - 🎇 🧨 ✨ 🎈 🎉 🎊 🎋 🎍 🎎 🎏 🎐\n🎑 🧧 🎀 🎁 - 🎫 🏆 🏅 🥇 🥈 🥉 ⚽ ⚾ 🥎 �� 🏐 🏈 🏉 🎾 - 🥏 🎳 🏏\n🏑 🏒 🥍 🏓 🏸 🥊 🥋 🥅 ⛳ 🎣 🤿 🎽 - 🎿 🛷 🥌 🎯 🪀 �� 🎱 🔮 🪄\n🧿 🎮 🎰 🎲 🧩 - 🧸 🪅 🪆 🃏 🀄 🎴 🎭 🎨 🧵 🪡 🧶 🪢 👓 🥽 🥼 - 🦺\n👔 👕 👖 🧣 🧤 🧥 🧦 👗 👘 🥻 🩱 🩲 🩳 - 👙 👚 👛 👜 👝 🎒 🩴 👞\n👟 🥾 🥿 �� 👡 🩰 - 👢 👑 👒 🎩 🎓 🧢 🪖 📿 💄 💍 💎 🔇 🔈 🔉 🔊 - \n📢 📣 📯 🔔 🔕 🎼 🎵 🎶 🎤 🎧 📻 🎷 🪗 🎸 - 🎹 🎺 🎻 🪕 🥁 🪘 📱\n📲 📞 📟 📠 🔋 🔌 💻 - 💽 💾 💿 📀 🧮 🎥 🎬 📺 📷 📸 📹 📼 🔍 🔎\n - 💡 🔦 🏮 🪔 📔 📕 📖 📗 📘 📙 📚 📓 📒 📃 � - � 📄 📰 📑 🔖 💰 🪙\n💴 💵 💶 💷 💸 💳 🧾 💹 - 📧 📨 📩 📤 📥 📦 📫 📪 📬 📭 📮 📝 💼\n📁 - 📂 📅 📆 📇 📈 📉 📊 📋 📌 📍 📎 📏 📐 🔒 🔓 - 🔏 🔐 🔑 🔨 🪓\n�� 🪃 🏹 🪚 🔧 🪛 🔩 🦯 🔗 - 🪝 🧰 🧲 🪜 🧪 🧫 🧬 🔬 🔭 📡 💉 🩸\n💊 🩹 - 🩺 🚪 🛗 🪞 🪟 🪑 🚽 🪠 🚿 🛁 🪤 🪒 🧴 🧷 🧹 - 🧺 🧻 🪣 🧼\n🪥 🧽 🧯 🛒 🚬 🪦 🗿 🪧 🏧 🚮 - 🚰 ♿ 🚹 🚺 🚻 🚼 🚾 🛂 🛃 🛄 🛅\n🚸 ⛔ 🚫 🚳 - 🚭 🚯 🚱 🚷 📵 🔞 🔃 🔄 🔙 🔚 🔛 🔜 🔝 🛐 🕎 - 🔯 ♈\n♉ ♊ ♋ ♌ ♍ ♎ ♏ ♐ ♑ ♒ ♓ ⛎ 🔀 🔁 🔂 - ⏩ ⏪ 🔼 ⏫ 🔽 ⏬\n🎦 🔅 🔆 📶 📳 📴 ➕ ➖ ➗ ❓ - ❔ ❕ ❗ 💱 💲 🔱 📛 🔰 ⭕ ✅ ❌\n❎ ➰ ➿ 🔟 🔠 - 🔡 🔢 🔣 🔤 🆎 🆑 🆒 🆓 🆔 🆕 🆖 🆗 🆘 🆙 🆚 - 🈁\n🈶 🈯 🉐 🈹 🈚 🈲 🉑 🈸 🈴 🈳 🈺 🈵 🔴 - 🟠 🟡 🟢 🔵 🟣 🟤 ⚫ ⚪\n🟥 🟧 🟨 🟩 🟦 🟪 🟫 - ⬛ ⬜ ◾ ◽ 🔶 🔷 🔸 🔹 🔺 🔻 �� 🔘 🔳 🔲\n🏁 - 🚩 🎌 🏴\n\nemoji reg/tags\n🇦🇨 🇦🇩 🇦🇪 🇦🇫 🇦 - 🇬 🇦🇮 🇦🇱 🇦🇲 🇦🇴 🇦🇶 🇦🇷 🇦🇸 🇦 - 🇹 🇦🇺 🇦🇼 🇦🇽 🇦🇿 🇧🇦 🇧🇧 🇧🇩 🇧 - 🇪\n��🇫 🇧🇬 🇧🇭 🇧🇮 🇧🇯 🇧🇱 🇧🇲 - 🇧🇳 🇧🇴 🇧🇶 🇧🇷 🇧🇸 🇧🇹 🇧🇻 🇧🇼 - ��🇾 🇧🇿 🇨🇦 🇨🇨 🇨🇩 🇨🇫\n🇨🇬 🇨🇭 - 🇨🇮 🇨🇰 🇨🇱 🇨🇲 🇨🇳 🇨🇴 🇨🇵 🇨🇷 - 🇨🇺 🇨🇻 🇨🇼 🇨🇽 🇨🇾 🇨🇿 🇩🇪 🇩🇬 - 🇩🇯 🇩🇰 🇩🇲\n🇩🇴 🇩🇿 🇪🇦 🇪🇨 🇪🇪 - 🇪🇬 🇪🇭 🇪🇷 🇪🇸 🇪🇹 🇪🇺 🇫🇮 🇫🇯 - 🇫🇰 🇫🇲 🇫🇴 🇫🇷 🇬🇦 🇬🇧 🇬🇩 🇬🇪\n - 🇬🇫 🇬🇬 🇬🇭 🇬🇮 🇬🇱 🇬🇲 🇬🇳 🇬🇵 - 🇬🇶 🇬🇷 🇬🇸 🇬�� 🇬🇺 🇬🇼 🇬🇾 🇭🇰 - 🇭🇲 🇭🇳 🇭🇷 🇭🇹 🇭🇺\n🇮🇨 🇮🇩 🇮🇪 - 🇮🇱 🇮🇲 🇮🇳 🇮🇴 🇮🇶 🇮🇷 🇮🇸 🇮🇹 - 🇯🇪 🇯🇲 🇯🇴 🇯🇵 🇰🇪 🇰🇬 🇰🇭 🇰🇮 - 🇰🇲 🇰🇳\n🇰🇵 🇰🇷 🇰🇼 🇰🇾 🇰🇿 🇱🇦 - 🇱🇧 🇱🇨 🇱🇮 🇱🇰 🇱🇷 🇱🇸 🇱🇹 🇱🇺 - ��🇻 🇱🇾 🇲🇦 🇲🇨 🇲🇩 🇲🇪 🇲🇫\n🇲🇬 - 🇲🇭 🇲🇰 🇲🇱 🇲🇲 🇲🇳 🇲🇴 🇲🇵 🇲🇶 - 🇲🇷 🇲🇸 🇲🇹 🇲🇺 🇲🇻 🇲🇼 🇲🇽 🇲🇾 - 🇲🇿 🇳🇦 🇳🇨 🇳🇪\n🇳🇫 🇳🇬 🇳🇮 🇳🇱 - 🇳🇴 🇳🇵 🇳🇷 🇳🇺 🇳🇿 🇴🇲 🇵🇦 🇵🇪 - 🇵🇫 🇵🇬 🇵🇭 🇵🇰 🇵🇱 🇵🇲 🇵🇳 🇵🇷 - 🇵🇸\n🇵🇹 🇵🇼 🇵🇾 🇶🇦 🇷🇪 🇷🇴 🇷🇸 - 🇷🇺 🇷🇼 🇸🇦 🇸�� 🇸🇨 🇸🇩 🇸🇪 🇸🇬 - 🇸🇭 🇸🇮 🇸🇯 🇸🇰 🇸🇱 🇸🇲\n🇸🇳 🇸🇴 - 🇸🇷 🇸🇸 🇸🇹 🇸🇻 🇸🇽 🇸🇾 🇸🇿 🇹🇦 - 🇹🇨 🇹🇩 🇹🇫 🇹🇬 🇹🇭 🇹🇯 🇹🇰 🇹🇱 - 🇹🇲 🇹🇳 🇹🇴\n🇹🇷 🇹🇹 🇹🇻 🇹🇼 🇹🇿 - 🇺🇦 🇺🇬 🇺🇲 🇺🇳 🇺🇸 🇺🇾 🇺🇿 🇻🇦 - ��🇨 🇻🇪 🇻🇬 🇻🇮 🇻🇳 🇻🇺 🇼🇫 🇼🇸\ - n🇽🇰 🇾🇪 🇾🇹 🇿🇦 🇿🇲 🇿🇼 🏴󠁧��󠁥 - 󠁮󠁧󠁿 🏴󠁧󠁢󠁳󠁣󠁴󠁿 🏴󠁧󠁢󠁷󠁬󠁳󠁿\n\n - modifier\n👋🏻 👋🏼 👋🏽 👋🏾 👋🏿 🤚🏻 🤚🏼 - 🤚🏽 🤚🏾 🤚🏿 🖐🏻 🖐🏼 🖐🏽 🖐🏾 🖐🏿 - ✋🏻 ✋🏼 ✋🏽 ✋🏾 ✋🏿 🖖🏻\n🖖🏼 🖖🏽 🖖 - 🏾 🖖🏿 👌🏻 👌🏼 👌🏽 👌🏾 👌🏿 🤌🏻 🤌 - 🏼 🤌🏽 🤌🏾 🤌🏿 ��🏻 🤏🏼 🤏🏽 🤏🏾 🤏 - 🏿 ✌🏻 ✌🏼\n✌🏽 ✌🏾 ✌🏿 🤞🏻 🤞🏼 🤞🏽 - 🤞🏾 🤞🏿 🤟🏻 🤟🏼 🤟🏽 🤟🏾 🤟🏿 🤘🏻 - 🤘🏼 🤘🏽 🤘🏾 🤘🏿 🤙🏻 🤙🏼 🤙🏽\n🤙🏾 - 🤙🏿 👈🏻 👈🏼 👈🏽 👈🏾 👈🏿 👉🏻 👉🏼 - 👉🏽 👉🏾 👉🏿 👆🏻 👆🏼 👆🏽 👆🏾 👆🏿 - 🖕🏻 🖕🏼 🖕🏽 🖕🏾\n🖕🏿 👇🏻 👇🏼 👇🏽 - 👇🏾 👇🏿 ☝🏻 ☝🏼 ☝🏽 ☝🏾 ☝🏿 👍🏻 👍� - � 👍🏽 👍🏾 👍🏿 👎🏻 👎🏼 👎🏽 👎🏾 👎 - 🏿\n✊🏻 ✊🏼 ✊🏽 ✊🏾 ✊🏿 👊🏻 👊🏼 👊🏽 - 👊🏾 👊🏿 🤛🏻 🤛🏼 🤛🏽 🤛🏾 🤛🏿 🤜🏻 - 🤜🏼 🤜🏽 🤜🏾 🤜🏿 👏🏻\n👏🏼 ��🏽 👏🏾 - 👏🏿 🙌🏻 🙌🏼 🙌🏽 🙌🏾 🙌🏿 👐🏻 👐🏼 - 👐🏽 👐🏾 👐🏿 🤲🏻 🤲🏼 ��🏽 🤲🏾 🤲🏿 - 🙏🏻 🙏🏼\n🙏🏽 🙏🏾 🙏🏿 ✍🏻 ✍🏼 ✍🏽 ✍ - 🏾 ✍🏿 💅🏻 💅🏼 💅🏽 💅🏾 💅🏿 🤳🏻 🤳 - 🏼 🤳🏽 🤳🏾 🤳🏿 💪🏻 💪🏼 💪🏽\n💪🏾 💪 - 🏿 🦵🏻 🦵🏼 🦵🏽 🦵🏾 🦵🏿 🦶🏻 🦶🏼 🦶 - 🏽 🦶🏾 🦶🏿 👂🏻 👂🏼 👂🏽 👂🏾 👂🏿 🦻 - 🏻 🦻🏼 🦻🏽 🦻🏾\n🦻🏿 👃🏻 👃🏼 👃🏽 👃 - 🏾 👃🏿 👶🏻 👶🏼 👶🏽 👶🏾 👶🏿 🧒🏻 🧒 - 🏼 🧒�� 🧒🏾 🧒🏿 👦🏻 👦🏼 👦🏽 👦🏾 👦 - 🏿\n👧🏻 👧🏼 👧🏽 👧🏾 👧🏿 🧑🏻 🧑🏼 🧑 - 🏽 🧑🏾 🧑🏿 👱🏻 👱🏼 👱🏽 👱🏾 👱🏿 👨 - 🏻 👨🏼 👨🏽 👨🏾 👨🏿 🧔🏻\n🧔🏼 ��🏽 - 🧔🏾 🧔🏿 👩🏻 👩🏼 👩🏽 👩🏾 👩🏿 🧓🏻 - 🧓🏼 🧓🏽 🧓🏾 🧓🏿 👴🏻 👴🏼 ��🏽 👴🏾 - 👴🏿 👵🏻 👵🏼\n👵🏽 👵🏾 👵🏿 🙍🏻 🙍🏼 - 🙍🏽 🙍🏾 🙍🏿 🙎🏻 🙎🏼 🙎🏽 🙎🏾 🙎🏿 - 🙅🏻 🙅🏼 🙅🏽 🙅🏾 🙅🏿 🙆🏻 🙆🏼 🙆🏽\n - 🙆🏾 🙆🏿 💁🏻 💁🏼 💁🏽 💁🏾 💁🏿 🙋🏻 - 🙋🏼 🙋🏽 🙋🏾 🙋🏿 🧏🏻 🧏🏼 🧏🏽 🧏🏾 - 🧏🏿 🙇🏻 🙇🏼 🙇🏽 🙇🏾\n🙇🏿 🤦🏻 🤦🏼 - 🤦🏽 🤦🏾 🤦🏿 🤷🏻 🤷🏼 🤷🏽 🤷🏾 🤷🏿 - 👮🏻 👮�� 👮🏽 👮🏾 👮🏿 🕵🏻 🕵🏼 🕵🏽 - 🕵🏾 🕵🏿\n💂🏻 💂🏼 💂🏽 💂🏾 💂🏿 🥷🏻 - 🥷🏼 🥷🏽 🥷🏾 🥷🏿 👷🏻 👷🏼 👷🏽 👷🏾 - 👷🏿 🤴🏻 🤴🏼 🤴🏽 🤴🏾 🤴🏿 👸🏻\n��🏼 - 👸🏽 👸🏾 👸🏿 👳🏻 👳🏼 👳🏽 👳🏾 👳🏿 - 👲🏻 👲🏼 👲🏽 👲🏾 👲🏿 🧕🏻 ��🏼 🧕🏽 - 🧕🏾 🧕🏿 🤵🏻 🤵🏼\n🤵🏽 🤵🏾 🤵🏿 👰🏻 - 👰🏼 👰🏽 👰🏾 👰🏿 🤰🏻 🤰🏼 🤰🏽 🤰🏾 - 🤰🏿 🤱🏻 🤱🏼 🤱🏽 🤱🏾 🤱🏿 👼🏻 👼🏼 - 👼🏽\n👼🏾 👼🏿 🎅🏻 🎅🏼 🎅🏽 🎅🏾 🎅🏿 - 🤶🏻 🤶🏼 🤶🏽 🤶🏾 🤶🏿 🦸🏻 🦸🏼 🦸🏽 - 🦸🏾 🦸🏿 🦹🏻 🦹🏼 🦹🏽 🦹🏾\n🦹🏿 🧙🏻 - 🧙🏼 🧙🏽 🧙🏾 🧙🏿 🧚🏻 🧚🏼 🧚🏽 🧚🏾 - 🧚🏿 🧛�� 🧛🏼 🧛🏽 🧛🏾 🧛🏿 🧜🏻 🧜🏼 - 🧜🏽 🧜🏾 🧜🏿\n🧝🏻 🧝🏼 🧝🏽 🧝🏾 🧝🏿 - 💆🏻 💆🏼 💆🏽 💆🏾 💆🏿 💇🏻 💇🏼 💇🏽 - 💇🏾 💇🏿 🚶🏻 🚶🏼 🚶🏽 🚶🏾 🚶🏿 🧍🏻\n - 🧍🏼 🧍🏽 🧍🏾 🧍🏿 🧎🏻 🧎🏼 🧎🏽 🧎🏾 - 🧎🏿 🏃🏻 🏃🏼 🏃🏽 🏃🏾 🏃🏿 ��🏻 💃🏼 - 💃🏽 💃🏾 💃🏿 🕺🏻 🕺🏼\n🕺🏽 🕺🏾 🕺🏿 - 🕴🏻 🕴🏼 🕴🏽 🕴🏾 🕴🏿 🧖🏻 🧖🏼 🧖🏽 - 🧖🏾 🧖🏿 🧗🏻 🧗🏼 🧗🏽 🧗🏾 🧗🏿 🏇🏻 - 🏇🏼 🏇🏽\n🏇🏾 🏇🏿 🏂🏻 🏂🏼 🏂🏽 🏂🏾 - 🏂🏿 🏌🏻 🏌🏼 🏌🏽 🏌🏾 🏌🏿 🏄🏻 🏄🏼 - 🏄🏽 🏄🏾 🏄🏿 🚣🏻 🚣🏼 🚣🏽 🚣🏾\n🚣🏿 - 🏊🏻 🏊🏼 🏊🏽 🏊🏾 🏊🏿 ⛹🏻 ⛹🏼 ⛹🏽 ⛹ - 🏾 ⛹🏿 🏋�� 🏋🏼 🏋🏽 🏋🏾 🏋🏿 🚴🏻 🚴 - 🏼 🚴🏽 🚴🏾 🚴🏿\n🚵🏻 🚵🏼 🚵🏽 🚵🏾 🚵 - 🏿 🤸🏻 🤸🏼 🤸🏽 🤸🏾 🤸🏿 🤽🏻 🤽🏼 🤽 - 🏽 🤽🏾 🤽🏿 🤾🏻 🤾🏼 🤾🏽 🤾🏾 🤾🏿 🤹 - 🏻\n🤹🏼 🤹🏽 🤹🏾 🤹🏿 🧘🏻 🧘🏼 🧘🏽 🧘 - 🏾 🧘🏿 🛀🏻 🛀🏼 🛀🏽 🛀🏾 🛀🏿 ��🏻 🛌 - 🏼 🛌🏽 🛌🏾 🛌🏿 👭🏻 👭🏼\n👭🏽 👭🏾 👭 - 🏿 👫🏻 👫🏼 👫🏽 👫🏾 👫🏿 👬🏻 👬🏼 👬 - 🏽 👬🏾 👬🏿 🏻 🏼 🏽 🏾 🏿\n\nzwj emoji\n👁️‍ - 🗨️ 👨‍🦰 👨🏻‍🦰 👨🏼‍🦰 👨🏽‍🦰 👨 - 🏾‍🦰 👨🏿‍🦰 👨‍🦱 👨🏻‍🦱 👨🏼‍🦱 - 👨🏽‍🦱 👨🏾‍🦱 👨🏿‍🦱 👨‍🦳 👨🏻‍ - 🦳 👨🏼‍🦳 👨🏽‍🦳 👨🏾‍🦳 👨🏿‍🦳 👨 - ‍🦲 👨🏻‍🦲\n👨🏼‍🦲 👨🏽‍🦲 👨🏾‍🦲 - 👨🏿‍🦲 👩‍🦰 👩🏻‍🦰 👩🏼‍🦰 👩🏽‍ - 🦰 👩🏾‍🦰 👩🏿‍🦰 🧑‍🦰 ��🏻‍🦰 🧑 - 🏼‍🦰 🧑🏽‍🦰 🧑🏾‍🦰 🧑🏿‍🦰 👩‍🦱 - 👩🏻‍🦱 👩🏼‍🦱 👩🏽‍🦱 👩🏾‍��\n👩 - 🏿‍🦱 🧑‍🦱 🧑🏻‍🦱 🧑🏼‍🦱 🧑🏽‍🦱 - 🧑🏾‍🦱 🧑🏿‍🦱 👩‍🦳 👩🏻‍🦳 👩🏼‍ - 🦳 👩🏽‍🦳 👩🏾‍🦳 👩🏿‍🦳 🧑‍🦳 🧑🏻 - ‍🦳 🧑🏼‍🦳 🧑🏽‍🦳 🧑🏾‍🦳 🧑🏿‍🦳 - ��‍🦲 👩🏻‍🦲\n👩🏼‍🦲 👩🏽‍🦲 👩🏾‍ - 🦲 👩🏿‍🦲 🧑‍🦲 🧑🏻‍🦲 🧑🏼‍🦲 🧑🏽 - ‍🦲 🧑🏾‍🦲 🧑🏿‍🦲 👱‍♀️ 👱🏻‍♀️ - 👱🏼‍♀️ 👱🏽‍♀️ 👱🏾‍♀️ 👱🏿‍♀️ - 👱‍♂️ 👱🏻‍♂️ 👱🏼‍♂️ 👱🏽‍♂️ 👱 - 🏾‍♂️\n👱🏿‍♂️ 🙍‍♂️ 🙍🏻‍♂️ 🙍🏼 - ‍♂️ 🙍🏽‍♂️ 🙍🏾‍♂️ 🙍🏿‍♂️ 🙍‍♀ - ️ 🙍🏻‍♀️ 🙍🏼‍♀️ 🙍🏽‍♀️ 🙍🏾‍♀ - ️ 🙍🏿‍♀️ 🙎‍♂️ 🙎🏻‍♂️ 🙎🏼‍♂️ - ��🏽‍♂️ 🙎🏾‍♂️ 🙎🏿‍♂️ 🙎‍♀️ 🙎 - 🏻‍♀️\n🙎🏼‍♀️ 🙎🏽‍♀️ 🙎🏾‍♀️ 🙎 - 🏿‍♀️ 🙅‍♂️ 🙅🏻‍♂️ 🙅🏼‍♂️ 🙅🏽 - ‍♂️ 🙅🏾‍♂️ 🙅🏿‍♂️ 🙅‍♀️ 🙅🏻‍♀ - ️ 🙅🏼‍♀️ 🙅🏽‍♀️ 🙅🏾‍♀️ 🙅🏿‍♀ - ️ 🙆‍♂️ 🙆🏻‍♂️ 🙆🏼‍♂️ 🙆🏽‍♂️ - 🙆🏾‍♂️\n🙆🏿‍♂️ 🙆‍♀️ 🙆🏻‍♀️ 🙆 - 🏼‍♀️ 🙆🏽‍♀️ 🙆🏾‍♀️ 🙆🏿‍♀️ 💁 - ‍♂️ 💁🏻‍♂️ 💁🏼‍♂️ 💁🏽‍♂️ 💁🏾 - ‍♂️ 💁🏿‍♂️ 💁‍♀️ 💁🏻‍♀️ 💁🏼‍♀ - ️ 💁🏽‍♀️ 💁🏾‍♀️ 💁🏿‍♀️ 🙋‍♂️ - 🙋🏻‍♂️\n🙋🏼‍♂️ 🙋🏽‍♂️ 🙋🏾‍♂️ - 🙋🏿‍♂️ 🙋‍♀️ 🙋🏻‍♀️ 🙋🏼‍♀️ 🙋 - 🏽‍♀️ 🙋🏾‍♀️ 🙋🏿‍♀️ 🧏‍♂️ 🧏🏻 - ‍♂️ 🧏🏼‍♂️ 🧏🏽‍♂️ 🧏🏾‍♂️ 🧏🏿 - ‍♂️ 🧏‍♀️ 🧏🏻‍♀️ 🧏🏼‍♀️ 🧏🏽‍♀ - ️ 🧏🏾‍♀️\n🧏🏿‍♀️ 🙇‍♂️ 🙇🏻‍♂️ - 🙇🏼‍♂️ 🙇🏽‍♂️ 🙇🏾‍♂️ 🙇🏿‍♂️ - 🙇‍♀️ 🙇🏻‍♀️ 🙇🏼‍♀️ 🙇🏽‍♀️ 🙇 - 🏾‍♀️ 🙇🏿‍♀️ 🤦‍♂️ 🤦🏻‍♂️ 🤦🏼 - ‍♂️ 🤦🏽‍♂️ 🤦🏾‍♂️ 🤦🏿‍♂️ 🤦‍♀ - ️ 🤦🏻‍♀️\n🤦🏼‍♀️ 🤦🏽‍♀️ 🤦🏾‍♀ - ️ 🤦🏿‍♀️ 🤷‍♂️ 🤷🏻‍♂️ 🤷🏼‍♂️ - 🤷🏽‍♂️ 🤷🏾‍♂️ 🤷🏿‍♂️ 🤷‍♀️ 🤷 - 🏻‍♀️ 🤷🏼‍♀️ 🤷🏽‍♀️ 🤷🏾‍♀️ 🤷 - 🏿‍♀️ 🧑‍⚕️ 🧑🏻‍⚕️ ��🏼‍⚕️ 🧑🏽 - ‍⚕️ 🧑🏾‍⚕️\n🧑🏿‍⚕️ 👨‍⚕️ 👨🏻‍ - ⚕️ 👨🏼‍⚕️ 👨🏽‍⚕️ 👨🏾‍⚕️ 👨🏿‍ - ⚕️ 👩‍⚕️ 👩🏻‍⚕️ 👩🏼‍⚕️ 👩🏽‍⚕️ - 👩🏾‍⚕️ 👩🏿‍⚕️ 🧑‍🎓 🧑🏻‍🎓 🧑🏼 - ‍🎓 🧑🏽‍🎓 🧑🏾‍🎓 🧑🏿‍🎓 👨‍🎓 👨 - 🏻‍🎓\n👨🏼‍🎓 👨🏽‍🎓 👨🏾‍🎓 👨🏿‍ - 🎓 👩‍🎓 👩🏻‍🎓 👩🏼‍🎓 👩🏽‍🎓 👩🏾 - ‍🎓 👩🏿‍🎓 🧑‍🏫 🧑🏻‍🏫 🧑🏼‍🏫 🧑 - 🏽‍🏫 🧑🏾‍🏫 🧑🏿‍🏫 👨‍🏫 👨🏻‍🏫 - 👨🏼‍🏫 👨🏽‍🏫 👨🏾‍🏫\n👨🏿‍🏫 👩‍ - 🏫 👩🏻‍🏫 👩🏼‍🏫 👩🏽‍🏫 👩🏾‍🏫 👩 - 🏿‍🏫 🧑‍⚖️ 🧑🏻‍⚖️ 🧑🏼‍⚖️ 🧑🏽‍ - ⚖️ 🧑🏾‍⚖️ 🧑🏿‍⚖️ 👨‍⚖️ 👨🏻‍⚖️ - 👨🏼‍⚖️ 👨🏽‍⚖️ 👨🏾‍⚖️ 👨🏿‍⚖️ - 👩‍⚖️ 👩🏻‍⚖️\n👩🏼‍⚖️ 👩🏽‍⚖️ 👩 - 🏾‍⚖️ 👩🏿‍⚖️ 🧑‍🌾 🧑🏻‍🌾 🧑🏼‍ - 🌾 🧑🏽‍🌾 🧑🏾‍🌾 🧑🏿‍🌾 👨‍🌾 👨🏻 - ‍🌾 👨🏼‍🌾 👨🏽‍🌾 👨🏾‍🌾 👨🏿‍🌾 - 👩‍🌾 👩🏻‍🌾 👩🏼‍🌾 👩🏽‍🌾 👩🏾‍ - 🌾\n👩��‍🌾 🧑‍🍳 🧑🏻‍🍳 🧑🏼‍🍳 🧑 - 🏽‍🍳 🧑🏾‍🍳 🧑🏿‍🍳 👨‍🍳 👨🏻‍🍳 - 👨🏼‍🍳 👨🏽‍🍳 👨🏾‍🍳 👨🏿‍🍳 👩‍ - 🍳 👩🏻‍🍳 👩🏼‍🍳 👩🏽‍🍳 👩🏾‍🍳 👩 - 🏿‍🍳 🧑‍🔧 ��🏻‍🔧\n🧑🏼‍🔧 🧑🏽‍🔧 - 🧑🏾‍🔧 🧑🏿‍🔧 👨‍🔧 👨🏻‍🔧 👨🏼‍ - 🔧 👨🏽‍🔧 👨🏾‍🔧 👨🏿‍🔧 👩‍🔧 👩🏻 - ‍🔧 👩🏼‍🔧 👩🏽‍🔧 👩🏾‍🔧 👩🏿‍🔧 - 🧑‍🏭 🧑🏻‍🏭 🧑🏼‍🏭 🧑🏽‍🏭 🧑🏾‍ - 🏭\n🧑🏿‍🏭 👨‍🏭 👨🏻‍🏭 👨🏼‍🏭 👨🏽 - ‍🏭 👨🏾‍🏭 👨🏿‍🏭 👩‍🏭 👩🏻‍🏭 👩 - 🏼‍🏭 👩🏽‍🏭 👩🏾‍🏭 👩🏿‍🏭 🧑‍💼 - 🧑🏻‍💼 🧑🏼‍💼 🧑🏽‍💼 🧑🏾‍💼 🧑🏿 - ‍💼 👨‍💼 👨🏻‍💼\n👨🏼‍💼 👨🏽‍💼 👨 - 🏾‍💼 👨🏿‍💼 👩‍💼 👩🏻‍💼 👩🏼‍💼 - 👩🏽‍💼 👩🏾‍💼 👩🏿‍💼 🧑‍🔬 🧑🏻‍ - 🔬 🧑🏼‍🔬 🧑🏽‍🔬 🧑🏾‍🔬 🧑🏿‍🔬 👨 - ‍🔬 👨🏻‍🔬 👨🏼‍🔬 👨🏽‍🔬 👨🏾‍🔬\n - 👨🏿‍🔬 👩‍🔬 👩🏻‍🔬 👩🏼‍🔬 👩🏽‍ - 🔬 👩🏾‍🔬 👩🏿‍🔬 🧑‍💻 🧑🏻‍💻 🧑🏼 - ‍💻 🧑🏽‍💻 🧑🏾‍💻 🧑🏿‍💻 👨‍💻 👨 - 🏻‍💻 👨🏼‍💻 👨🏽‍💻 👨🏾‍💻 👨🏿‍ - 💻 👩‍💻 👩🏻‍💻\n👩🏼‍💻 👩🏽‍💻 👩� - �‍💻 👩🏿‍💻 🧑‍🎤 🧑🏻‍🎤 🧑🏼‍🎤 - 🧑🏽‍🎤 🧑🏾‍🎤 🧑🏿‍🎤 👨‍🎤 👨🏻‍ - 🎤 👨🏼‍🎤 👨🏽‍🎤 👨🏾‍🎤 👨🏿‍🎤 👩 - ‍🎤 👩🏻‍🎤 👩🏼‍🎤 👩🏽‍🎤 👩🏾‍🎤\n - 👩🏿‍�� 🧑‍🎨 🧑🏻‍🎨 🧑🏼‍🎨 🧑🏽‍ - 🎨 🧑🏾‍🎨 🧑🏿‍🎨 👨‍🎨 👨🏻‍🎨 👨🏼 - ‍🎨 👨🏽‍🎨 👨🏾‍🎨 👨🏿‍🎨 👩‍🎨 👩 - 🏻‍🎨 👩🏼‍🎨 👩🏽‍🎨 👩🏾‍🎨 👩🏿‍ - 🎨 🧑‍✈️ 🧑🏻‍✈️\n🧑🏼‍✈️ 🧑🏽‍✈️ - 🧑🏾‍✈️ 🧑🏿‍✈️ 👨‍✈️ 👨🏻‍✈️ 👨 - 🏼‍✈️ 👨🏽‍✈️ 👨🏾‍✈️ 👨🏿‍✈️ 👩 - ‍✈️ 👩🏻‍✈️ 👩🏼‍✈️ 👩🏽‍✈️ 👩🏾 - ‍✈️ 👩🏿‍✈️ 🧑‍🚀 🧑🏻‍🚀 🧑🏼‍🚀 - 🧑🏽‍🚀 🧑🏾‍🚀\n🧑🏿‍🚀 👨‍🚀 👨🏻‍ - 🚀 👨🏼‍🚀 👨🏽‍🚀 👨🏾‍🚀 👨🏿‍🚀 👩 - ‍🚀 👩🏻‍🚀 👩🏼‍🚀 👩🏽‍🚀 👩🏾‍🚀 - 👩🏿‍🚀 🧑‍🚒 🧑🏻‍🚒 🧑🏼‍🚒 🧑🏽‍ - 🚒 🧑🏾‍🚒 🧑🏿‍🚒 👨‍🚒 👨🏻‍🚒\n👨🏼 - ‍🚒 👨🏽‍🚒 👨🏾‍🚒 👨🏿‍🚒 👩‍🚒 👩 - 🏻‍🚒 👩🏼‍🚒 👩🏽‍🚒 👩🏾‍🚒 👩🏿‍ - 🚒 👮‍♂️ 👮🏻‍♂️ 👮🏼‍♂️ 👮🏽‍♂️ - 👮🏾‍♂️ 👮🏿‍♂️ 👮‍♀️ 👮🏻‍♀️ 👮 - 🏼‍♀️ 👮🏽‍♀️ 👮🏾‍♀️\n👮🏿‍♀️ 🕵 - ️‍♂️ 🕵🏻‍♂️ 🕵🏼‍♂️ 🕵🏽‍♂️ 🕵 - 🏾‍♂️ 🕵🏿‍♂️ 🕵️‍♀️ 🕵🏻‍♀️ 🕵 - 🏼‍♀️ 🕵🏽‍♀️ 🕵🏾‍♀️ 🕵🏿‍♀️ 💂 - ‍♂️ 💂🏻‍♂️ 💂🏼‍♂️ 💂🏽‍♂️ 💂🏾 - ‍♂️ 💂🏿‍♂️ 💂‍♀️ 💂🏻‍♀️\n💂🏼‍ - ♀️ 💂🏽‍♀️ 💂🏾‍♀️ 💂🏿‍♀️ 👷‍♂️ - 👷🏻‍♂️ 👷🏼‍♂️ 👷🏽‍♂️ 👷🏾‍♂️ - 👷🏿‍♂️ 👷‍♀️ 👷🏻‍♀️ 👷��‍♀️ 👷 - 🏽‍♀️ 👷🏾‍♀️ 👷🏿‍♀️ 👳‍♂️ 👳🏻 - ‍♂️ 👳🏼‍♂️ 👳🏽‍♂️ 👳🏾‍♂️\n👳🏿 - ‍♂️ 👳‍♀️ 👳🏻‍♀️ 👳🏼‍♀️ 👳🏽‍♀ - ️ 👳🏾‍♀️ 👳🏿‍♀️ 🤵‍♂️ 🤵🏻‍♂️ - 🤵🏼‍♂️ 🤵🏽‍♂️ 🤵🏾‍♂️ 🤵🏿‍♂️ - 🤵‍♀️ 🤵🏻‍♀️ 🤵🏼‍♀️ 🤵🏽‍♀️ 🤵 - 🏾‍♀️ 🤵🏿‍♀️ 👰‍♂️ 👰🏻‍♂️\n👰🏼 - ‍♂️ 👰🏽‍♂️ 👰🏾‍♂️ 👰🏿‍♂️ 👰‍♀ - ️ 👰🏻‍♀️ 👰🏼‍♀️ 👰🏽‍♀️ 👰🏾‍♀ - ️ ��🏿‍♀️ 👩‍🍼 👩🏻‍🍼 👩🏼‍🍼 👩 - 🏽‍🍼 👩🏾‍🍼 👩🏿‍🍼 👨‍🍼 👨🏻‍🍼 - 👨🏼‍🍼 👨🏽‍🍼 👨🏾‍🍼\n👨🏿‍🍼 🧑‍ - 🍼 🧑🏻‍🍼 🧑🏼‍🍼 🧑🏽‍🍼 🧑🏾‍🍼 🧑 - 🏿‍🍼 🧑‍�� 🧑🏻‍🎄 🧑🏼‍🎄 🧑🏽‍🎄 - 🧑🏾‍🎄 🧑🏿‍🎄 🦸‍♂️ 🦸🏻‍♂️ 🦸🏼 - ‍♂️ 🦸🏽‍♂️ 🦸��‍♂️ 🦸🏿‍♂️ 🦸‍ - ♀️ 🦸🏻‍♀️\n🦸🏼‍♀️ 🦸🏽‍♀️ 🦸🏾‍ - ♀️ 🦸🏿‍♀️ 🦹‍♂️ 🦹🏻‍♂️ ��🏼‍♂ - ️ 🦹🏽‍♂️ 🦹🏾‍♂️ 🦹🏿‍♂️ 🦹‍♀️ - 🦹🏻‍♀️ 🦹🏼‍♀️ 🦹🏽‍♀️ 🦹🏾‍♀️ - 🦹🏿‍♀️ 🧙‍♂️ 🧙🏻‍♂️ 🧙🏼‍♂️ 🧙 - 🏽‍♂️ 🧙🏾‍♂️\n🧙🏿‍♂️ 🧙‍♀️ 🧙🏻 - ‍♀️ 🧙🏼‍♀️ 🧙🏽‍♀️ 🧙🏾‍♀️ 🧙🏿 - ‍♀️ 🧚‍♂️ 🧚🏻‍♂️ 🧚🏼‍♂️ 🧚🏽‍♂ - ️ 🧚🏾‍♂️ 🧚🏿‍♂️ 🧚‍♀️ 🧚��‍♀️ - 🧚🏼‍♀️ 🧚🏽‍♀️ 🧚🏾‍♀️ 🧚🏿‍♀️ - 🧛‍♂️ 🧛🏻‍♂️\n🧛🏼‍♂️ 🧛🏽‍♂️ 🧛 - 🏾‍♂️ 🧛🏿‍♂️ 🧛‍♀️ 🧛🏻‍♀️ 🧛🏼 - ‍♀️ 🧛🏽‍♀️ 🧛🏾‍♀️ 🧛🏿‍♀️ 🧜‍♂ - ️ 🧜🏻‍♂️ 🧜🏼‍♂️ 🧜🏽‍♂️ 🧜🏾‍♂ - ️ 🧜🏿‍♂️ 🧜‍♀️ 🧜🏻‍♀️ 🧜🏼‍♀️ - 🧜🏽‍♀️ 🧜🏾‍♀️\n🧜🏿‍♀️ 🧝‍♂️ 🧝 - 🏻‍♂️ 🧝🏼‍♂️ 🧝🏽‍♂️ 🧝🏾‍♂️ 🧝 - 🏿‍♂️ 🧝‍♀️ 🧝🏻‍♀️ 🧝🏼‍♀️ 🧝🏽 - ‍♀️ ��🏾‍♀️ 🧝🏿‍♀️ 🧞‍♂️ 🧞‍♀ - ️ 🧟‍♂️ 🧟‍♀️ 💆‍♂️ 💆🏻‍♂️ 💆🏼 - ‍♂️ 💆🏽‍♂️\n💆🏾‍♂️ 💆🏿‍♂️ 💆‍ - ♀️ 💆🏻‍♀️ 💆🏼‍♀️ 💆🏽‍♀️ 💆🏾‍ - ♀️ 💆🏿‍♀️ 💇‍♂️ 💇🏻‍♂️ 💇🏼‍♂️ - 💇🏽‍♂️ 💇🏾‍♂️ 💇🏿‍♂️ 💇‍♀️ 💇 - 🏻‍♀️ 💇🏼‍♀️ 💇🏽‍♀️ 💇🏾‍♀️ 💇 - 🏿‍♀️ ��‍♂️\n🚶🏻‍♂️ 🚶🏼‍♂️ 🚶 - 🏽‍♂️ 🚶🏾‍♂️ 🚶🏿‍♂️ 🚶‍♀️ 🚶🏻 - ‍♀️ 🚶🏼‍♀️ 🚶🏽‍♀️ 🚶🏾‍♀️ 🚶🏿 - ‍♀️ 🧍‍♂️ 🧍🏻‍♂️ 🧍🏼‍♂️ 🧍🏽‍♂ - ️ 🧍🏾‍♂️ 🧍🏿‍♂️ 🧍‍♀️ 🧍🏻‍♀️ - 🧍🏼‍♀️ 🧍🏽‍♀️\n🧍🏾‍♀️ 🧍🏿‍♀️ - 🧎‍♂️ 🧎🏻‍♂️ 🧎🏼‍♂️ 🧎🏽‍♂️ 🧎 - 🏾‍♂️ 🧎🏿‍♂️ 🧎‍♀️ 🧎🏻‍♀️ 🧎🏼 - ‍♀️ 🧎🏽‍♀️ 🧎🏾‍♀️ 🧎🏿‍♀️ 🧑‍ - 🦯 🧑🏻‍🦯 🧑🏼‍🦯 ��🏽‍🦯 🧑🏾‍🦯 - 🧑🏿‍🦯 👨‍🦯\n👨🏻‍🦯 👨🏼‍🦯 👨🏽‍ - 🦯 👨🏾‍🦯 👨🏿‍🦯 👩‍🦯 👩🏻‍🦯 👩🏼 - ‍🦯 👩🏽‍🦯 👩🏾‍🦯 👩🏿‍🦯 🧑‍🦼 🧑 - 🏻‍🦼 🧑🏼‍🦼 🧑🏽‍🦼 🧑🏾‍🦼 🧑🏿‍ - 🦼 👨‍🦼 👨🏻‍🦼 👨🏼‍🦼 👨🏽‍🦼\n👨🏾 - ‍🦼 👨🏿‍🦼 👩‍🦼 👩🏻‍🦼 👩🏼‍🦼 👩 - 🏽‍🦼 👩🏾‍🦼 👩🏿‍🦼 🧑‍🦽 🧑🏻‍🦽 - 🧑🏼‍🦽 🧑🏽‍🦽 🧑🏾‍🦽 🧑🏿‍🦽 👨‍ - 🦽 👨🏻‍🦽 👨🏼‍🦽 👨🏽‍🦽 👨🏾‍🦽 👨 - 🏿‍🦽 👩‍🦽\n👩🏻‍🦽 👩🏼‍🦽 👩🏽‍🦽 - 👩🏾‍🦽 👩🏿‍🦽 🏃‍♂️ 🏃🏻‍♂️ 🏃🏼 - ‍♂️ 🏃🏽‍♂️ 🏃🏾‍♂️ 🏃🏿‍♂️ 🏃‍♀ - ️ 🏃🏻‍♀️ 🏃🏼‍♀️ 🏃🏽‍♀️ 🏃🏾‍♀ - ️ 🏃🏿‍♀️ 👯‍♂️ 👯‍♀️ 🧖‍♂️ 🧖🏻 - ‍♂️\n🧖🏼‍♂️ 🧖🏽‍♂️ 🧖🏾‍♂️ 🧖🏿 - ‍♂️ 🧖‍♀️ 🧖🏻‍♀️ 🧖🏼‍♀️ 🧖🏽‍♀ - ️ 🧖🏾‍♀️ 🧖🏿‍♀️ 🧗‍♂️ 🧗��‍♂️ - 🧗🏼‍♂️ 🧗🏽‍♂️ 🧗🏾‍♂️ 🧗🏿‍♂️ - 🧗‍♀️ 🧗🏻‍♀️ 🧗🏼‍♀️ 🧗🏽‍♀️ 🧗 - 🏾‍♀️\n🧗🏿‍♀️ 🏌️‍♂️ 🏌🏻‍♂️ 🏌 - 🏼‍♂️ 🏌🏽‍♂️ 🏌🏾‍♂️ 🏌🏿‍♂️ 🏌 - ️‍♀️ 🏌🏻‍♀️ 🏌🏼‍♀️ 🏌🏽‍♀️ 🏌 - 🏾‍♀️ 🏌🏿‍♀️ 🏄‍♂️ 🏄🏻‍♂️ 🏄🏼 - ‍♂️ 🏄🏽‍♂️ 🏄🏾‍♂️ 🏄🏿‍♂️ ��‍ - ♀️ 🏄🏻‍♀️\n🏄🏼‍♀️ 🏄🏽‍♀️ 🏄🏾‍ - ♀️ 🏄🏿‍♀️ 🚣‍♂️ 🚣🏻‍♂️ 🚣🏼‍♂️ - 🚣🏽‍♂️ 🚣🏾‍♂️ 🚣🏿‍♂️ 🚣‍♀️ 🚣 - 🏻‍♀️ 🚣🏼‍♀️ 🚣🏽‍♀️ 🚣🏾‍♀️ 🚣 - 🏿‍♀️ 🏊‍♂️ 🏊🏻‍♂️ 🏊🏼‍♂️ 🏊🏽 - ‍♂️ 🏊🏾‍♂️\n🏊🏿‍♂️ 🏊‍♀️ 🏊🏻‍ - ♀️ 🏊🏼‍♀️ 🏊🏽‍♀️ 🏊🏾‍♀️ 🏊🏿‍ - ♀️ ⛹️‍♂️ ⛹🏻‍♂️ ⛹🏼‍♂️ ⛹🏽‍♂️ - ⛹🏾‍♂️ ⛹🏿‍♂️ ⛹️‍♀️ ⛹🏻‍♀️ ⛹🏼 - ‍♀️ ⛹🏽‍♀️ ⛹🏾‍♀️ ⛹🏿‍♀️ 🏋️‍♂ - ️ 🏋🏻‍♂️\n🏋🏼‍♂️ 🏋🏽‍♂️ 🏋🏾‍♂ - ️ 🏋🏿‍♂️ 🏋️‍♀️ 🏋🏻‍♀️ 🏋🏼‍♀️ - 🏋🏽‍♀️ 🏋🏾‍♀️ 🏋🏿‍♀️ 🚴‍♂️ 🚴 - 🏻‍♂️ 🚴🏼‍♂️ 🚴🏽‍♂️ 🚴🏾‍♂️ 🚴 - 🏿‍♂️ 🚴‍♀️ 🚴🏻‍♀️ 🚴🏼‍♀️ 🚴🏽 - ‍♀️ 🚴🏾‍♀️\n🚴🏿‍♀️ 🚵‍♂️ 🚵🏻‍ - ♂️ 🚵🏼‍♂️ 🚵🏽‍♂️ 🚵🏾‍♂️ 🚵🏿‍ - ♂️ 🚵‍♀️ 🚵🏻‍♀️ 🚵🏼‍♀️ 🚵🏽‍♀️ - 🚵🏾‍♀️ 🚵🏿‍♀️ 🤸‍♂️ ��🏻‍♂️ - 🤸🏼‍♂️ 🤸🏽‍♂️ 🤸🏾‍♂️ 🤸🏿‍♂️ - 🤸‍♀️ 🤸🏻‍♀️\n🤸🏼‍♀️ 🤸🏽‍♀️ 🤸 - 🏾‍♀️ 🤸🏿‍♀️ 🤼‍♂️ 🤼‍♀️ 🤽‍♂️ - 🤽🏻‍♂️ 🤽🏼‍♂️ 🤽🏽‍♂️ 🤽🏾‍♂️ - 🤽🏿‍♂️ 🤽‍♀️ 🤽🏻‍♀️ 🤽🏼‍♀️ 🤽 - 🏽‍♀️ 🤽🏾‍♀️ 🤽🏿‍♀️ 🤾‍♂️ 🤾🏻 - ‍♂️ 🤾🏼‍♂️\n🤾🏽‍♂️ 🤾🏾‍♂️ 🤾🏿 - ‍♂️ 🤾‍♀️ 🤾🏻‍♀️ 🤾🏼‍♀️ 🤾🏽‍♀ - ️ 🤾🏾‍♀️ 🤾🏿‍♀️ 🤹‍♂️ 🤹🏻‍♂️ - 🤹��‍♂️ 🤹🏽‍♂️ 🤹🏾‍♂️ 🤹🏿‍♂️ - 🤹‍♀️ 🤹🏻‍♀️ 🤹🏼‍♀️ 🤹🏽‍♀️ 🤹 - 🏾‍♀️ 🤹🏿‍♀️\n🧘‍♂️ 🧘🏻‍♂️ 🧘🏼 - ‍♂️ 🧘🏽‍♂️ 🧘🏾‍♂️ 🧘🏿‍♂️ 🧘‍♀ - ️ 🧘🏻‍♀️ 🧘🏼‍♀️ 🧘🏽‍♀️ 🧘🏾‍♀ - ️ 🧘🏿‍♀️ 🧑‍🤝‍🧑 🧑🏻‍🤝‍🧑🏻 🧑 - 🏻‍🤝‍🧑🏼 🧑🏻‍🤝‍🧑🏽 🧑🏻‍🤝‍�� - 🏾 🧑🏻‍🤝‍🧑🏿 🧑🏼‍🤝‍🧑🏻 🧑🏼‍🤝 - ‍🧑🏼 🧑🏼‍🤝‍🧑🏽\n🧑🏼‍🤝‍🧑🏾 🧑 - 🏼‍🤝‍🧑🏿 🧑🏽‍🤝‍🧑🏻 🧑🏽‍🤝‍🧑 - 🏼 🧑🏽‍🤝‍🧑🏽 🧑🏽‍🤝‍🧑🏾 🧑🏽‍🤝 - ‍🧑🏿 🧑🏾‍🤝‍🧑🏻 🧑🏾‍🤝‍🧑🏼 🧑🏾 - ‍🤝‍🧑🏽 🧑🏾‍🤝‍🧑🏾 🧑🏾‍🤝‍🧑🏿 - 🧑🏿‍🤝‍🧑🏻 🧑🏿‍🤝‍🧑🏼 🧑🏿‍🤝‍ - 🧑🏽 🧑🏿‍🤝‍🧑🏾 🧑🏿‍🤝‍🧑🏿 👩🏻‍ - 🤝‍👩🏼 👩🏻‍🤝‍��🏽 👩🏻‍🤝‍👩🏾 - 👩🏻‍🤝‍👩🏿\n👩🏼‍🤝‍👩🏻 👩🏼‍🤝‍ - 👩🏽 👩🏼‍🤝‍👩🏾 👩🏼‍🤝‍👩🏿 👩🏽‍ - 🤝‍👩🏻 👩🏽‍🤝‍👩🏼 👩🏽‍🤝‍👩🏾 👩 - 🏽‍🤝‍👩🏿 👩🏾‍🤝‍👩🏻 👩🏾‍🤝‍👩 - 🏼 👩🏾‍🤝‍👩🏽 👩🏾‍🤝‍👩🏿 👩🏿‍🤝 - ‍👩🏻 👩🏿‍🤝‍👩🏼 👩🏿‍🤝‍👩🏽 👩🏿 - ‍🤝‍👩🏾 👩🏻‍🤝‍👨🏼 👩🏻‍🤝‍👨🏽 - 👩🏻‍🤝‍👨🏾 👩🏻‍🤝‍👨🏿 👩🏼‍🤝‍ - ��🏻\n👩🏼‍🤝‍👨🏽 👩🏼‍🤝‍👨🏾 👩🏼 - ‍🤝‍👨🏿 👩🏽‍🤝‍👨🏻 👩🏽‍🤝‍👨🏼 - 👩🏽‍🤝‍👨🏾 👩🏽‍🤝‍👨🏿 👩🏾‍🤝‍ - 👨🏻 👩🏾‍🤝‍👨🏼 👩🏾‍🤝‍👨🏽 👩🏾‍ - 🤝‍👨🏿 👩🏿‍🤝‍👨🏻 👩🏿‍🤝‍👨🏼 👩 - 🏿‍🤝‍👨🏽 👩🏿‍🤝‍👨🏾 👨🏻‍🤝‍👨 - 🏼 👨🏻‍🤝‍👨🏽 👨🏻‍🤝‍👨🏾 👨🏻‍🤝 - ‍👨🏿 👨🏼‍🤝‍👨🏻 👨🏼‍🤝‍👨🏽\n👨 - 🏼‍🤝‍👨🏾 👨🏼‍🤝‍👨🏿 👨🏽‍🤝‍👨 - 🏻 👨🏽‍🤝‍👨🏼 👨🏽‍🤝‍👨🏾 👨🏽‍🤝 - ‍👨🏿 👨🏾‍🤝‍👨🏻 👨🏾‍🤝‍👨🏼 👨🏾 - ‍🤝‍👨🏽 👨🏾‍🤝‍👨🏿 👨🏿‍🤝‍👨🏻 - 👨🏿‍🤝‍👨🏼 👨🏿‍🤝‍👨🏽 👨🏿‍🤝‍ - 👨🏾 👩‍❤️‍💋‍👨 👨‍❤️‍💋‍👨 👩‍ - ❤️‍💋‍👩 👩‍❤️‍👨 👨‍❤️‍👨 👩‍❤ - ️‍👩 ��‍👩‍👦\n👨‍👩‍👧 👨‍👩‍👧‍ - 👦 👨‍👩‍👦‍👦 👨‍👩‍👧‍👧 👨‍👨‍ - 👦 👨‍👨‍👧 👨‍👨‍👧‍👦 👨‍👨‍👦‍ - 👦 👨‍👨‍👧‍👧 👩‍👩‍👦 👩‍👩‍👧 👩 - ‍👩‍👧‍👦 👩‍👩‍👦‍👦 👩‍👩‍👧‍👧 - 👨‍👦 👨‍👦‍👦 👨‍👧 👨‍👧‍👦 👨‍👧 - ‍👧 👩‍👦 👩‍👦‍👦\n👩‍👧 👩‍👧‍👦 - 👩‍👧‍👧 🐕‍🦺 🐈‍⬛ 🐻‍❄️ 🏳️‍🌈 - 🏳️‍⚧️ 🏴‍☠️ -STATUS:CONFIRMED -DTSTART;VALUE=DATE:20200218 -DTEND;VALUE=DATE:20200219 -END:VEVENT -BEGIN:VEVENT -CREATED:20200221T214249 -DTSTAMP:20200221T214249 -LAST-MODIFIED:20200221T214249 -UID:7MSPGE5T0S25ANPMN8Y337 -SUMMARY:UTF-8 Demo ©Markus Kuhn -CLASS:PUBLIC -DESCRIPTION:UTF-8 encoded sample plain-text file\n‾‾‾‾‾‾‾‾ - ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ - ‾‾‾‾\n\nMarkus Kuhn [ˈmaʳkʊs kuːn] — 2002-07-25 CC BY\n\n\nThe ASCII compatible UTF-8 encoding used - in this plain-text file\nis defined in Unicode\, ISO 10646-1\, and RFC 227 - 9.\n\n\nUsing Unicode/UTF-8\, you can write in emails and source code thin - gs such as\n\nMathematics and sciences:\n\n ∮ E⋅da = Q\, n → ∞\, - ∑ f(i) = ∏ g(i)\, ⎧⎡⎛┌─────┐⎞⎤⎫\n - ⎪⎢⎜│a²+b³ ⎟⎥⎪\n - ∀x∈ℝ: ⌈x⌉ = −⌊−x⌋\, α ∧ ¬β = ¬(¬α ∨ β)\, - ⎪⎢⎜│───── ⎟⎥⎪\n - ⎪⎢⎜⎷ c₈ ⎟⎥⎪\n ℕ ⊆ ℕ₀ ⊂ ℤ ⊂ ℚ - ⊂ ℝ ⊂ ℂ\, ⎨⎢⎜ ⎟⎥⎬\n - ⎪⎢⎜ ∞ ⎟⎥⎪\n ⊥ < a - ≠ b ≡ c ≤ d ≪ ⊤ ⇒ (⟦A⟧ ⇔ ⟪B⟫)\, ⎪⎢⎜ ⎲ - ⎟⎥⎪\n ⎪⎢⎜ ⎳a - ⁱ-bⁱ⎟⎥⎪\n 2H₂ + O₂ ⇌ 2H₂O\, R = 4.7 kΩ\, ⌀ 200 mm - ⎩⎣⎝i=1 ⎠⎦⎭\n\nLinguistics and dictionaries:\n\n ði ınt - əˈnæʃənəl fəˈnɛtık əsoʊsiˈeıʃn\n Y [ˈʏpsilɔn]\, Yen [j - ɛn]\, Yoga [ˈjoːgɑ]\n\nAPL:\n\n ((V⍳V)=⍳⍴V)/V←\,V ⌷← - ⍳→⍴∆∇⊃‾⍎⍕⌈\n\nNicer typography in plain text files:\n\ - n ╔══════════════════════ - ════════════════════╗\n ║ - ║\n ║ • ‘single’ and - “double” quotes ║\n ║ - ║\n ║ • Curly apostrophes: “We’ve been here” ║\n - ║ ║\n ║ • Latin-1 apos - trophe and accents: '´` ║\n ║ - ║\n ║ • ‚deutsche‘ „Anführungszeichen“ ║\n - ║ ║\n ║ • †\, ‡\, - ‰\, •\, 3–4\, —\, −5/+5\, ™\, … ║\n ║ - ║\n ║ • ASCII safety test: 1lI|\, 0O - D\, 8B ║\n ║ ╭───────── - ╮ ║\n ║ • the euro symbol: │ 14.95 € │ - ║\n ║ ╰─────────╯ - ║\n ╚═════════════════════ - ═════════════════════╝\n\nComb - ining characters:\n\n STARGΛ̊TE SG-1\, a = v̇ = r̈\, a⃑ ⊥ b⃑\n\ - nGreek (in Polytonic):\n\n The Greek anthem:\n\n Σὲ γνωρίζω - ἀπὸ τὴν κόψη\n τοῦ σπαθιοῦ τὴν τρομερ - ή\,\n σὲ γνωρίζω ἀπὸ τὴν ὄψη\n ποὺ μὲ - βία μετράει τὴ γῆ.\n\n ᾿Απ᾿ τὰ κόκκαλα - βγαλμένη\n τῶν ῾Ελλήνων τὰ ἱερά\n κα - ὶ σὰν πρῶτα ἀνδρειωμένη\n χαῖρε\, ὦ χα - ῖρε\, ᾿Ελευθεριά!\n\n From a speech of Demosthenes in the - 4th century BC:\n\n Οὐχὶ ταὐτὰ παρίσταταί μο - ι γιγνώσκειν\, ὦ ἄνδρες ᾿Αθηναῖοι\,\n ὅ - ταν τ᾿ εἰς τὰ πράγματα ἀποβλέψω καὶ - ὅταν πρὸς τοὺς\n λόγους οὓς ἀκούω· το - ὺς μὲν γὰρ λόγους περὶ τοῦ\n τιμωρήσα - σθαι Φίλιππον ὁρῶ γιγνομένους\, τὰ δὲ - πράγματ᾿\n εἰς τοῦτο προήκοντα\, ὥσθ - ᾿ ὅπως μὴ πεισόμεθ᾿ αὐτοὶ\n πρότερον - κακῶς σκέψασθαι δέον. οὐδέν οὖν ἄλλο - μοι δοκοῦσιν\n οἱ τὰ τοιαῦτα λέγοντες - ἢ τὴν ὑπόθεσιν\, περὶ ἧς βουλεύεσθαι\ - ,\n οὐχὶ τὴν οὖσαν παριστάντες ὑμῖν ἁ - μαρτάνειν. ἐγὼ δέ\, ὅτι μέν\n ποτ᾿ ἐξ - ῆν τῇ πόλει καὶ τὰ αὑτῆς ἔχειν ἀσφαλ - ῶς καὶ Φίλιππον\n τιμωρήσασθαι\, καὶ μ - άλ᾿ ἀκριβῶς οἶδα· ἐπ᾿ ἐμοῦ γάρ\, οὐ - πάλαι\n γέγονεν ταῦτ᾿ ἀμφότερα· νῦν - μέντοι πέπεισμαι τοῦθ᾿ ἱκανὸν\n προλ - αβεῖν ἡμῖν εἶναι τὴν πρώτην\, ὅπως το - ὺς συμμάχους\n σώσομεν. ἐὰν γὰρ τοῦτο - βεβαίως ὑπάρξῃ\, τότε καὶ περὶ τοῦ\n - τίνα τιμωρήσεταί τις καὶ ὃν τρόπον ἐ - ξέσται σκοπεῖν· πρὶν δὲ\n τὴν ἀρχὴν - ὀρθῶς ὑποθέσθαι\, μάταιον ἡγοῦμαι πε - ρὶ τῆς\n τελευτῆς ὁντινοῦν ποιεῖσθαι - λόγον.\n\n Δημοσθένους\, Γ´ ᾿Ολυνθιακὸς - \n\nGeorgian:\n\n From a Unicode conference invitation:\n\n გთხო - ვთ ახლავე გაიაროთ რეგისტრა - ცია Unicode-ის მეათე საერთაშორის - ო\n კონფერენციაზე დასასწრებ - ად\, რომელიც გაიმართება 10-12 მა - რტს\,\n ქ. მაინცში\, გერმანიაში - . კონფერენცია შეჰკრებს ერთა - დ მსოფლიოს\n ექსპერტებს ისე - თ დარგებში როგორიცაა ინტერ - ნეტი და Unicode-ი\,\n ინტერნაციონა - ლიზაცია და ლოკალიზაცია\, Unicode- - ის გამოყენება\n ოპერაციულ ს - ისტემებსა\, და გამოყენებით პ - როგრამებში\, შრიფტებში\,\n ტე - ქსტების დამუშავებასა და მრ - ავალენოვან კომპიუტერულ სის - ტემებში.\n\nRussian:\n\n From a Unicode conference invitati - on:\n\n Зарегистрируйтесь сейчас на Десяту - ю Международную Конференцию по\n Unicode\, к - оторая состоится 10-12 марта 1997 года в Майн - це в Германии.\n Конференция соберет шир - окий круг экспертов по вопросам глобаль - ного\n Интернета и Unicode\, локализации и ин - тернационализации\, воплощению и\n приме - нению Unicode в различных операционных сист - емах и программных\n приложениях\, шрифт - ах\, верстке и многоязычных компьютерных - системах.\n\nThai (UCS Level 2):\n\n Excerpt from a poetry on Th - e Romance of The Three Kingdoms (a Chinese\n classic 'San Gua'):\n\n [-- - --------------------------|------------------------]\n ๏ แผ่น - ดินฮั่นเสื่อมโทรมแสนสังเ - วช พระปกเกศกองบู๊กู้ขึ้นใ - หม่\n สิบสองกษัตริย์ก่อนหน - ้าแลถัดไป สององค์ไซร้โง - ่เขลาเบาปัญญา\n ทรงนับถือ - ขันทีเป็นที่พึ่ง บ้านเ - มืองจึงวิปริตเป็นนักหนา\n - โฮจิ๋นเรียกทัพทั่วหัวเมื - องมา หมายจะฆ่ามดชั่วตั - วสำคัญ\n เหมือนขับไสไล่เส - ือจากเคหา รับหมาป่าเข้า - มาเลยอาสัญ\n ฝ่ายอ้องอุ้นย - ุแยกให้แตกกัน ใช้สาวนั - ้นเป็นชนวนชื่นชวนใจ\n พลั - นลิฉุยกุยกีกลับก่อเหตุ - ช่างอาเพศจริงหนาฟ้าร้องไ - ห้\n ต้องรบราฆ่าฟันจนบรรลั - ย ฤๅหาใครค้ำชูกู้บรรลั - งก์ ฯ\n\n (The above is a two-column text. If combining character - s are handled\n correctly\, the lines of the second column should be alig - ned with the\n | character above.)\n\nEthiopian:\n\n Proverbs in the Amh - aric language:\n\n ሰማይ አይታረስ ንጉሥ አይከሰስ። - \n ብላ ካለኝ እንደአባቴ በቆመጠኝ።\n ጌጥ ያ - ለቤቱ ቁምጥና ነው።\n ደሀ በሕልሙ ቅቤ ባይጠ - ጣ ንጣት በገደለው።\n የአፍ ወለምታ በቅቤ አ - ይታሽም።\n አይጥ በበላ ዳዋ ተመታ።\n ሲተረጉ - ሙ ይደረግሙ።\n ቀስ በቀስ፥ ዕንቁላል በእግሩ - ይሄዳል።\n ድር ቢያብር አንበሳ ያስር።\n ሰ - ው እንደቤቱ እንጅ እንደ ጉረቤቱ አይተዳደርም - ።\n እግዜር የከፈተውን ጉሮሮ ሳይዘጋው አይድ - ርም።\n የጎረቤት ሌባ፥ ቢያዩት ይስቅ ባያዩት - ያጠልቅ።\n ሥራ ከመፍታት ልጄን ላፋታት።\n - ዓባይ ማደሪያ የለው፥ ግንድ ይዞ ይዞራል።\n - የእስላም አገሩ መካ የአሞራ አገሩ ዋርካ።\n - ተንጋሎ ቢተፉ ተመልሶ ባፉ።\n ወዳጅህ ማር ቢ - ሆን ጨርስህ አትላሰው።\n እግርህን በፍራሽህ - ልክ ዘርጋ።\n\nRunes:\n\n ᚻᛖ ᚳᚹᚫᚦ ᚦᚫᛏ ᚻᛖ - ᛒᚢᛞᛖ ᚩᚾ ᚦᚫᛗ ᛚᚪᚾᛞᛖ ᚾᚩᚱᚦᚹᛖᚪᚱᛞ - ᚢᛗ ᚹᛁᚦ ᚦᚪ ᚹᛖᛥᚫ\n\n (Old English\, which transcribed - into Latin reads 'He cwaeth that he\n bude thaem lande northweardum with - tha Westsae.' and means 'He said\n that he lived in the northern land ne - ar the Western Sea.')\n\nBraille:\n\n ⡌⠁⠧⠑ ⠼⠁⠒ ⡍⠜⠇ - ⠑⠹⠰⠎ ⡣⠕⠌\n\n ⡍⠜⠇⠑⠹ ⠺⠁⠎ ⠙⠑⠁⠙⠒ ⠞ - ⠕ ⠃⠑⠛⠔ ⠺⠊⠹⠲ ⡹⠻⠑ ⠊⠎ ⠝⠕ ⠙⠳⠃⠞\n ⠱ - ⠁⠞⠑⠧⠻ ⠁⠃⠳⠞ ⠹⠁⠞⠲ ⡹⠑ ⠗⠑⠛⠊⠌⠻ ⠕⠋ - ⠙⠊⠎ ⠃⠥⠗⠊⠁⠇ ⠺⠁⠎\n ⠎⠊⠛⠝⠫ ⠃⠹ ⠹⠑ - ⠊⠇⠻⠛⠹⠍⠁⠝⠂ ⠹⠑ ⠊⠇⠻⠅⠂ ⠹⠑ ⠥⠝⠙⠻⠞ - ⠁⠅⠻⠂\n ⠁⠝⠙ ⠹⠑ ⠡⠊⠑⠋ ⠍⠳⠗⠝⠻⠲ ⡎⠊⠗ - ⠕⠕⠛⠑ ⠎⠊⠛⠝⠫ ⠊⠞⠲ ⡁⠝⠙\n ⡎⠊⠗⠕⠕⠛⠑ - ⠰⠎ ⠝⠁⠍⠑ ⠺⠁⠎ ⠛⠕⠕⠙ ⠥⠏⠕⠝ ⠰⡡⠁⠝⠛⠑ - ⠂ ⠋⠕⠗ ⠁⠝⠹⠹⠔⠛ ⠙⠑\n ⠡⠕⠎⠑ ⠞⠕ ⠏⠥⠞ - ⠙⠊⠎ ⠙⠁⠝⠙ ⠞⠕⠲\n\n ⡕⠇⠙ ⡍⠜⠇⠑⠹ ⠺⠁⠎ - ⠁⠎ ⠙⠑⠁⠙ ⠁⠎ ⠁ ⠙⠕⠕⠗⠤⠝⠁⠊⠇⠲\n\n ⡍⠔ - ⠙⠖ ⡊ ⠙⠕⠝⠰⠞ ⠍⠑⠁⠝ ⠞⠕ ⠎⠁⠹ ⠹⠁⠞ ⡊ ⠅ - ⠝⠪⠂ ⠕⠋ ⠍⠹\n ⠪⠝ ⠅⠝⠪⠇⠫⠛⠑⠂ ⠱⠁⠞ ⠹ - ⠻⠑ ⠊⠎ ⠏⠜⠞⠊⠊⠥⠇⠜⠇⠹ ⠙⠑⠁⠙ ⠁⠃⠳⠞\n - ⠁ ⠙⠕⠕⠗⠤⠝⠁⠊⠇⠲ ⡊ ⠍⠊⠣⠞ ⠙⠁⠧⠑ ⠃⠑⠲ - ⠔⠊⠇⠔⠫⠂ ⠍⠹⠎⠑⠇⠋⠂ ⠞⠕\n ⠗⠑⠛⠜⠙ ⠁ - ⠊⠕⠋⠋⠔⠤⠝⠁⠊⠇ ⠁⠎ ⠹⠑ ⠙⠑⠁⠙⠑⠌ ⠏⠊⠑ - ⠊⠑ ⠕⠋ ⠊⠗⠕⠝⠍⠕⠝⠛⠻⠹\n ⠔ ⠹⠑ ⠞⠗⠁⠙⠑ - ⠲ ⡃⠥⠞ ⠹⠑ ⠺⠊⠎⠙⠕⠍ ⠕⠋ ⠳⠗ ⠁⠝⠊⠑⠌⠕ - ⠗⠎\n ⠊⠎ ⠔ ⠹⠑ ⠎⠊⠍⠊⠇⠑⠆ ⠁⠝⠙ ⠍⠹ ⠥⠝ - ⠙⠁⠇⠇⠪⠫ ⠙⠁⠝⠙⠎\n ⠩⠁⠇⠇ ⠝⠕⠞ ⠙⠊⠌⠥ - ⠗⠃ ⠊⠞⠂ ⠕⠗ ⠹⠑ ⡊⠳⠝⠞⠗⠹⠰⠎ ⠙⠕⠝⠑ ⠋ - ⠕⠗⠲ ⡹⠳\n ⠺⠊⠇⠇ ⠹⠻⠑⠋⠕⠗⠑ ⠏⠻⠍⠊⠞ ⠍ - ⠑ ⠞⠕ ⠗⠑⠏⠑⠁⠞⠂ ⠑⠍⠏⠙⠁⠞⠊⠊⠁⠇⠇⠹⠂ - ⠹⠁⠞\n ⡍⠜⠇⠑⠹ ⠺⠁⠎ ⠁⠎ ⠙⠑⠁⠙ ⠁⠎ ⠁ ⠙ - ⠕⠕⠗⠤⠝⠁⠊⠇⠲\n\n (The first couple of paragraphs of "A Chr - istmas Carol" by Dickens)\n\nCompact font selection example text:\n\n ABC - DEFGHIJKLMNOPQRSTUVWXYZ /0123456789\n abcdefghijklmnopqrstuvwxyz £©µÀ - ÆÖÞßéöÿ\n –—‘“”„†•…‰™œŠŸž€ ΑΒΓΔΩ - αβγδω АБВГДабвгд\n ∀∂∈ℝ∧∪≡∞ ↑↗↨↻ - ⇣ ┐┼╔╘░►☺♀ fi�⑀₂ἠḂӥẄɐː⍎אԱა\n\nGree - tings in various languages:\n\n Hello world\, Καλημέρα κόσμ - ε\, コンニチハ\n\nBox drawing alignment tests: - █\n - ▉\n ╔══╦══╗ ┌──┬──┐ - ╭──┬──╮ ╭──┬──╮ ┏━━┳━━┓ ┎ - ┒┏┑ ╷ ╻ ┏┯┓ ┌┰┐ ▊ ╱╲╱╲╳╳╳\n - ║┌─╨─┐║ │╔═╧═╗│ │╒═╪═╕│ │ - ╓─╁─╖│ ┃┌─╂─┐┃ ┗╃╄┙ ╶┼╴╺╋ - ╸┠┼┨ ┝╋┥ ▋ ╲╱╲╱╳╳╳\n ║│╲ ╱│║ - │║ ║│ ││ │ ││ │║ ┃ ║│ ┃│ ╿ │┃ - ┍╅╆┓ ╵ ╹ ┗┷┛ └┸┘ ▌ ╱╲╱╲╳╳╳ - \n ╠╡ ╳ ╞╣ ├╢ ╟┤ ├┼─┼─┼┤ ├╫─ - ╂─╫┤ ┣┿╾┼╼┿┫ ┕┛┖┚ ┌┄┄┐ ╎ ┏ - ┅┅┓ ┋ ▍ ╲╱╲╱╳╳╳\n ║│╱ ╲│║ │║ - ║│ ││ │ ││ │║ ┃ ║│ ┃│ ╽ │┃ ░░▒ - ▒▓▓██ ┊ ┆ ╎ ╏ ┇ ┋ ▎\n ║└─╥─┘║ - │╚═╤═╝│ │╘═╪═╛│ │╙─╀─╜│ ┃ - └─╂─┘┃ ░░▒▒▓▓██ ┊ ┆ ╎ ╏ ┇ ┋ ▏ - \n ╚══╩══╝ └──┴──┘ ╰──┴──╯ - ╰──┴──╯ ┗━━┻━━┛ ▗▄▖▛▀▜ └╌ - ╌┘ ╎ ┗╍╍┛ ┋ ▁▂▃▄▅▆▇█\n - ▝▀▘▙▄▟ -STATUS:CONFIRMED -DTSTART;TZID=Europe/Berlin:20200217T100000 -DTEND;TZID=Europe/Berlin:20200217T130000 -END:VEVENT -BEGIN:VEVENT -CREATED:20200221T214509 -DTSTAMP:20200221T214509 -LAST-MODIFIED:20200221T214509 -UID:Q61XC6MORCNB0Y1OH7G2B -SUMMARY:Emoji Keyboard/Display Test Data v1️⃣2️⃣ ©️Unicode -CLASS:PUBLIC -DESCRIPTION:# emoji-test.txt\n# Date: 2019-10-12\, 00:43:03 GMT\n# © 2019 - Unicode®\, Inc.\n# Unicode and the Unicode Logo are registered trademarks - of Unicode\, Inc. in the U.S. and other countries.\n# For terms of use\, - see http://www.unicode.org/terms_of_use.html\n#\n# Emoji Keyboard/Display - Test Data for UTS #51\n# Version: 12.1\n#\n# For documentation and usage\, - see http://www.unicode.org/reports/tr51\n#\n# This file provides data for - testing which emoji forms should be in keyboards and which should also be - displayed/processed.\n# Format: code points\; status # emoji name\n# - Code points — list of one or more hex code points\, separated by spaces\ - n# Status\n# component — an Emoji_Component\,\n# - excluding Regional_Indicators\, ASCII\, and non-E - moji.\n# fully-qualified — a fully-qualified emoji (see ED-18 - in UTS #51)\,\n# excluding Emoji_Component\n# - minimally-qualified — a minimally-qualified emoji (see ED-18a in U - TS #51)\n# unqualified — a unqualified emoji (See ED-19 in - UTS #51)\n# Notes:\n# • This includes the emoji components that need - emoji presentation (skin tone and hair)\n# when isolated\, but omits t - he components that need not have an emoji\n# presentation when isolate - d.\n# • The RGI set is covered by the listed fully-qualified emoji. \n - # • The listed minimally-qualified and unqualified cover all cases whe - re an\n# element of the RGI set is missing one or more emoji presentat - ion selectors.\n# • The file is in CLDR order\, not codepoint order. T - his is recommended (but not required!) for keyboard palettes.\n# • The - groups and subgroups are illustrative. See the Emoji Order chart for more - information.\n\n\n# group: Smileys & Emotion\n\n# subgroup: face-smiling\ - n1F600 \; fully-qualified # 😀 - E2.0 grinning face\n1F603 \; fully-qu - alified # 😃 E2.0 grinning face with big eyes\n1F604 - \; fully-qualified # 😄 E2.0 grinning face wit - h smiling eyes\n1F601 \; fully-qualif - ied # 😁 E2.0 beaming face with smiling eyes\n1F606 - \; fully-qualified # 😆 E2.0 grinning squinting - face\n1F605 \; fully-qualified # - 😅 E2.0 grinning face with sweat\n1F923 - \; fully-qualified # 🤣 E4.0 rolling on the floor laughing\n1F - 602 \; fully-qualified # 😂 E2. - 0 face with tears of joy\n1F642 \; fu - lly-qualified # 🙂 E2.0 slightly smiling face\n1F643 - \; fully-qualified # 🙃 E2.0 upside-down face\ - n1F609 \; fully-qualified # 😉 - E2.0 winking face\n1F60A \; fully-qua - lified # 😊 E2.0 smiling face with smiling eyes\n1F607 - \; fully-qualified # 😇 E2.0 smiling face wi - th halo\n\n# subgroup: face-affection\n1F970 - \; fully-qualified # 🥰 E11.0 smiling face with hearts\n1F60 - D \; fully-qualified # 😍 E2.0 - smiling face with heart-eyes\n1F929 \ - ; fully-qualified # 🤩 E5.0 star-struck\n1F618 - \; fully-qualified # 😘 E2.0 face blowing a kiss\n1F - 617 \; fully-qualified # 😗 E2. - 0 kissing face\n263A FE0F \; fully-qualif - ied # ☺️ E2.0 smiling face\n263A - \; unqualified # ☺ E2.0 smiling face\n1F61A - \; fully-qualified # 😚 E2.0 kissing face with - closed eyes\n1F619 \; fully-qualifie - d # 😙 E2.0 kissing face with smiling eyes\n\n# subgroup: face-tongu - e\n1F60B \; fully-qualified # - 😋 E2.0 face savoring food\n1F61B \ - ; fully-qualified # 😛 E2.0 face with tongue\n1F61C - \; fully-qualified # 😜 E2.0 winking face with - tongue\n1F92A \; fully-qualified - # 🤪 E5.0 zany face\n1F61D \; fully - -qualified # 😝 E2.0 squinting face with tongue\n1F911 - \; fully-qualified # 🤑 E2.0 money-mouth fac - e\n\n# subgroup: face-hand\n1F917 \; - fully-qualified # 🤗 E2.0 hugging face\n1F92D - \; fully-qualified # 🤭 E5.0 face with hand over mout - h\n1F92B \; fully-qualified # - 🤫 E5.0 shushing face\n1F914 \; ful - ly-qualified # 🤔 E2.0 thinking face\n\n# subgroup: face-neutral-ske - ptical\n1F910 \; fully-qualified - # 🤐 E2.0 zipper-mouth face\n1F928 - \; fully-qualified # 🤨 E5.0 face with raised eyebrow\n1F610 - \; fully-qualified # 😐 E2.0 neutral f - ace\n1F611 \; fully-qualified # - 😑 E2.0 expressionless face\n1F636 - \; fully-qualified # 😶 E2.0 face without mouth\n1F60F - \; fully-qualified # 😏 E2.0 smirking face\n - 1F612 \; fully-qualified # 😒 E - 2.0 unamused face\n1F644 \; fully-qua - lified # 🙄 E2.0 face with rolling eyes\n1F62C - \; fully-qualified # 😬 E2.0 grimacing face\n1F925 - \; fully-qualified # 🤥 E4.0 lyi - ng face\n\n# subgroup: face-sleepy\n1F60C - \; fully-qualified # 😌 E2.0 relieved face\n1F614 - \; fully-qualified # 😔 E2.0 pensive face\n1 - F62A \; fully-qualified # 😪 E2 - .0 sleepy face\n1F924 \; fully-qualif - ied # 🤤 E4.0 drooling face\n1F634 - \; fully-qualified # 😴 E2.0 sleeping face\n\n# subgroup: face-u - nwell\n1F637 \; fully-qualified # - 😷 E2.0 face with medical mask\n1F912 - \; fully-qualified # 🤒 E2.0 face with thermometer\n1F915 - \; fully-qualified # 🤕 E2.0 face wit - h head-bandage\n1F922 \; fully-qualif - ied # 🤢 E4.0 nauseated face\n1F92E - \; fully-qualified # 🤮 E5.0 face vomiting\n1F927 - \; fully-qualified # 🤧 E4.0 sneezing face\n - 1F975 \; fully-qualified # 🥵 E - 11.0 hot face\n1F976 \; fully-qualifi - ed # 🥶 E11.0 cold face\n1F974 - \; fully-qualified # 🥴 E11.0 woozy face\n1F635 - \; fully-qualified # 😵 E2.0 dizzy face\n1F92F - \; fully-qualified # 🤯 E5.0 explod - ing head\n\n# subgroup: face-hat\n1F920 - \; fully-qualified # 🤠 E4.0 cowboy hat face\n1F973 - \; fully-qualified # 🥳 E11.0 partying face\ - n\n# subgroup: face-glasses\n1F60E \; - fully-qualified # 😎 E2.0 smiling face with sunglasses\n1F913 - \; fully-qualified # 🤓 E2.0 nerd fa - ce\n1F9D0 \; fully-qualified # - 🧐 E5.0 face with monocle\n\n# subgroup: face-concerned\n1F615 - \; fully-qualified # 😕 E2.0 confused fa - ce\n1F61F \; fully-qualified # - 😟 E2.0 worried face\n1F641 \; full - y-qualified # 🙁 E2.0 slightly frowning face\n2639 FE0F - \; fully-qualified # ☹️ E2.0 frowning face\n2 - 639 \; unqualified # ☹ E2. - 0 frowning face\n1F62E \; fully-quali - fied # 😮 E2.0 face with open mouth\n1F62F - \; fully-qualified # 😯 E2.0 hushed face\n1F632 - \; fully-qualified # 😲 E2.0 astonished - face\n1F633 \; fully-qualified # - 😳 E2.0 flushed face\n1F97A \; ful - ly-qualified # 🥺 E11.0 pleading face\n1F626 - \; fully-qualified # 😦 E2.0 frowning face with open m - outh\n1F627 \; fully-qualified # - 😧 E2.0 anguished face\n1F628 \; fu - lly-qualified # 😨 E2.0 fearful face\n1F630 - \; fully-qualified # 😰 E2.0 anxious face with sweat\n1 - F625 \; fully-qualified # 😥 E2 - .0 sad but relieved face\n1F622 \; fu - lly-qualified # 😢 E2.0 crying face\n1F62D - \; fully-qualified # 😭 E2.0 loudly crying face\n1F631 - \; fully-qualified # 😱 E2.0 fac - e screaming in fear\n1F616 \; fully-q - ualified # 😖 E2.0 confounded face\n1F623 - \; fully-qualified # �� E2.0 persevering face\n1F61E - \; fully-qualified # 😞 E2.0 disa - ppointed face\n1F613 \; fully-qualifi - ed # 😓 E2.0 downcast face with sweat\n1F629 - \; fully-qualified # 😩 E2.0 weary face\n1F62B - \; fully-qualified # 😫 E2.0 tired fac - e\n1F971 \; fully-qualified # - 🥱 E12.1 yawning face\n\n# subgroup: face-negative\n1F624 - \; fully-qualified # 😤 E2.0 face with steam - from nose\n1F621 \; fully-qualified - # 😡 E2.0 pouting face\n1F620 \; - fully-qualified # 😠 E2.0 angry face\n1F92C - \; fully-qualified # 🤬 E5.0 face with symbols on mout - h\n1F608 \; fully-qualified # - 😈 E2.0 smiling face with horns\n1F47F - \; fully-qualified # 👿 E2.0 angry face with horns\n1F480 - \; fully-qualified # 💀 E2.0 skull\n2 - 620 FE0F \; fully-qualified # ☠️ - E2.0 skull and crossbones\n2620 \; u - nqualified # ☠ E2.0 skull and crossbones\n\n# subgroup: face-cos - tume\n1F4A9 \; fully-qualified # - 💩 E2.0 pile of poo\n1F921 \; fully - -qualified # 🤡 E4.0 clown face\n1F479 - \; fully-qualified # 👹 E2.0 ogre\n1F47A - \; fully-qualified # 👺 E2.0 goblin\n1F47B - \; fully-qualified # 👻 E2.0 ghost\n1F - 47D \; fully-qualified # 👽 E2. - 0 alien\n1F47E \; fully-qualified - # 👾 E2.0 alien monster\n1F916 \; - fully-qualified # 🤖 E2.0 robot\n\n# subgroup: cat-face\n1F63A - \; fully-qualified # 😺 E2.0 grinnin - g cat\n1F638 \; fully-qualified # - 😸 E2.0 grinning cat with smiling eyes\n1F639 - \; fully-qualified # 😹 E2.0 cat with tears of joy\n1F63 - B \; fully-qualified # 😻 E2.0 - smiling cat with heart-eyes\n1F63C \; - fully-qualified # 😼 E2.0 cat with wry smile\n1F63D - \; fully-qualified # 😽 E2.0 kissing cat\n1F64 - 0 \; fully-qualified # 🙀 E2.0 - weary cat\n1F63F \; fully-qualified - # 😿 E2.0 crying cat\n1F63E \; f - ully-qualified # 😾 E2.0 pouting cat\n\n# subgroup: monkey-face\n1F6 - 48 \; fully-qualified # 🙈 E2.0 - see-no-evil monkey\n1F649 \; fully-q - ualified # 🙉 E2.0 hear-no-evil monkey\n1F64A - \; fully-qualified # 🙊 E2.0 speak-no-evil monkey\n\n - # subgroup: emotion\n1F48B \; fully-q - ualified # 💋 E2.0 kiss mark\n1F48C - \; fully-qualified # 💌 E2.0 love letter\n1F498 - \; fully-qualified # 💘 E2.0 heart with arrow\ - n1F49D \; fully-qualified # 💝 - E2.0 heart with ribbon\n1F496 \; full - y-qualified # 💖 E2.0 sparkling heart\n1F497 - \; fully-qualified # 💗 E2.0 growing heart\n1F493 - \; fully-qualified # 💓 E2.0 beatin - g heart\n1F49E \; fully-qualified - # 💞 E2.0 revolving hearts\n1F495 - \; fully-qualified # 💕 E2.0 two hearts\n1F49F - \; fully-qualified # 💟 E2.0 heart decoration\n2763 - FE0F \; fully-qualified # ❣️ E2.0 - heart exclamation\n2763 \; unqualif - ied # ❣ E2.0 heart exclamation\n1F494 - \; fully-qualified # 💔 E2.0 broken heart\n2764 FE0F - \; fully-qualified # ❤️ E2.0 red hear - t\n2764 \; unqualified # ❤ - E2.0 red heart\n1F9E1 \; fully-quali - fied # 🧡 E5.0 orange heart\n1F49B - \; fully-qualified # 💛 E2.0 yellow heart\n1F49A - \; fully-qualified # 💚 E2.0 green heart\n1F49 - 9 \; fully-qualified # 💙 E2.0 - blue heart\n1F49C \; fully-qualified - # 💜 E2.0 purple heart\n1F90E \ - ; fully-qualified # 🤎 E12.1 brown heart\n1F5A4 - \; fully-qualified # 🖤 E4.0 black heart\n1F90D - \; fully-qualified # 🤍 E12.1 whit - e heart\n1F4AF \; fully-qualified - # 💯 E2.0 hundred points\n1F4A2 \; - fully-qualified # 💢 E2.0 anger symbol\n1F4A5 - \; fully-qualified # 💥 E2.0 collision\n1F4AB - \; fully-qualified # 💫 E2.0 dizzy\n1 - F4A6 \; fully-qualified # 💦 E2 - .0 sweat droplets\n1F4A8 \; fully-qua - lified # 💨 E2.0 dashing away\n1F573 FE0F - \; fully-qualified # 🕳️ E2.0 hole\n1F573 - \; unqualified # 🕳 E2.0 hole\n1F4A3 - \; fully-qualified # 💣 E2.0 bomb\n1F4A - C \; fully-qualified # 💬 E2.0 - speech balloon\n1F441 FE0F 200D 1F5E8 FE0F \; fully-qualif - ied # 👁️‍🗨️ E2.0 eye in speech bubble\n1F441 200D 1F5E8 FE - 0F \; unqualified # 👁‍🗨️ E2.0 eye i - n speech bubble\n1F441 FE0F 200D 1F5E8 \; unqualified - # 👁️‍🗨 E2.0 eye in speech bubble\n1F441 200D 1F5E8 - \; unqualified # 👁‍🗨 E2.0 eye in spe - ech bubble\n1F5E8 FE0F \; fully-qualified - # 🗨️ E2.0 left speech bubble\n1F5E8 - \; unqualified # 🗨 E2.0 left speech bubble\n1F5EF FE0F - \; fully-qualified # 🗯️ E2.0 righ - t anger bubble\n1F5EF \; unqualified - # 🗯 E2.0 right anger bubble\n1F4AD - \; fully-qualified # 💭 E2.0 thought balloon\n1F4A4 - \; fully-qualified # 💤 E2.0 zzz\n\n# - Smileys & Emotion subtotal: 160\n# Smileys & Emotion subtotal: 160 w/o m - odifiers\n\n# group: People & Body\n\n# subgroup: hand-fingers-open\n1F44B - \; fully-qualified # 👋 E2.0 w - aving hand\n1F44B 1F3FB \; fully-qualified - # 👋🏻 E2.0 waving hand: light skin tone\n1F44B 1F3FC - \; fully-qualified # 👋🏼 E2.0 waving hand: med - ium-light skin tone\n1F44B 1F3FD \; fully-q - ualified # 👋🏽 E2.0 waving hand: medium skin tone\n1F44B 1F3FE - \; fully-qualified # 👋🏾 E2.0 waving - hand: medium-dark skin tone\n1F44B 1F3FF \ - ; fully-qualified # 👋🏿 E2.0 waving hand: dark skin tone\n1F91A - \; fully-qualified # 🤚 E4.0 rai - sed back of hand\n1F91A 1F3FB \; fully-qual - ified # 🤚🏻 E4.0 raised back of hand: light skin tone\n1F91A 1F3F - C \; fully-qualified # 🤚🏼 E4.0 ra - ised back of hand: medium-light skin tone\n1F91A 1F3FD - \; fully-qualified # 🤚🏽 E4.0 raised back of hand: me - dium skin tone\n1F91A 1F3FE \; fully-qualif - ied # 🤚🏾 E4.0 raised back of hand: medium-dark skin tone\n1F91A - 1F3FF \; fully-qualified # 🤚🏿 E4. - 0 raised back of hand: dark skin tone\n1F590 FE0F - \; fully-qualified # 🖐️ E2.0 hand with fingers splayed\n1 - F590 \; unqualified # 🖐 E2 - .0 hand with fingers splayed\n1F590 1F3FB \ - ; fully-qualified # 🖐🏻 E2.0 hand with fingers splayed: light ski - n tone\n1F590 1F3FC \; fully-qualified - # 🖐🏼 E2.0 hand with fingers splayed: medium-light skin tone\n1F590 1 - F3FD \; fully-qualified # 🖐🏽 E2.0 - hand with fingers splayed: medium skin tone\n1F590 1F3FE - \; fully-qualified # 🖐🏾 E2.0 hand with fingers sp - layed: medium-dark skin tone\n1F590 1F3FF \ - ; fully-qualified # 🖐🏿 E2.0 hand with fingers splayed: dark skin - tone\n270B \; fully-qualified # - ✋ E2.0 raised hand\n270B 1F3FB \; fully - -qualified # ✋🏻 E2.0 raised hand: light skin tone\n270B 1F3FC - \; fully-qualified # ✋🏼 E2.0 raised - hand: medium-light skin tone\n270B 1F3FD \ - ; fully-qualified # ✋🏽 E2.0 raised hand: medium skin tone\n270B 1 - F3FE \; fully-qualified # ✋🏾 E2.0 - raised hand: medium-dark skin tone\n270B 1F3FF - \; fully-qualified # ✋🏿 E2.0 raised hand: dark skin tone\n1 - F596 \; fully-qualified # 🖖 E2 - .0 vulcan salute\n1F596 1F3FB \; fully-qual - ified # 🖖🏻 E2.0 vulcan salute: light skin tone\n1F596 1F3FC - \; fully-qualified # 🖖🏼 E2.0 vulcan s - alute: medium-light skin tone\n1F596 1F3FD - \; fully-qualified # 🖖🏽 E2.0 vulcan salute: medium skin tone\n1F - 596 1F3FE \; fully-qualified # 🖖🏾 - E2.0 vulcan salute: medium-dark skin tone\n1F596 1F3FF - \; fully-qualified # 🖖🏿 E2.0 vulcan salute: dark sk - in tone\n\n# subgroup: hand-fingers-partial\n1F44C - \; fully-qualified # 👌 E2.0 OK hand\n1F44C 1F3FB - \; fully-qualified # 👌🏻 E2.0 OK hand: - light skin tone\n1F44C 1F3FC \; fully-qual - ified # 👌🏼 E2.0 OK hand: medium-light skin tone\n1F44C 1F3FD - \; fully-qualified # 👌🏽 E2.0 OK hand - : medium skin tone\n1F44C 1F3FE \; fully-qu - alified # 👌🏾 E2.0 OK hand: medium-dark skin tone\n1F44C 1F3FF - \; fully-qualified # 👌🏿 E2.0 OK han - d: dark skin tone\n1F90F \; fully-qua - lified # 🤏 E12.1 pinching hand\n1F90F 1F3FB - \; fully-qualified # 🤏🏻 E12.1 pinching hand: light skin - tone\n1F90F 1F3FC \; fully-qualified # - 🤏🏼 E12.1 pinching hand: medium-light skin tone\n1F90F 1F3FD - \; fully-qualified # 🤏🏽 E12.1 pinching ha - nd: medium skin tone\n1F90F 1F3FE \; fully- - qualified # 🤏🏾 E12.1 pinching hand: medium-dark skin tone\n1F90F - 1F3FF \; fully-qualified # 🤏🏿 E1 - 2.1 pinching hand: dark skin tone\n270C FE0F - \; fully-qualified # ✌️ E2.0 victory hand\n270C - \; unqualified # ✌ E2.0 victory hand\n27 - 0C 1F3FB \; fully-qualified # ✌🏻 - E2.0 victory hand: light skin tone\n270C 1F3FC - \; fully-qualified # ✌🏼 E2.0 victory hand: medium-light skin - tone\n270C 1F3FD \; fully-qualified # - ✌🏽 E2.0 victory hand: medium skin tone\n270C 1F3FE - \; fully-qualified # ✌🏾 E2.0 victory hand: medium- - dark skin tone\n270C 1F3FF \; fully-qualif - ied # ✌🏿 E2.0 victory hand: dark skin tone\n1F91E - \; fully-qualified # 🤞 E4.0 crossed fingers\n - 1F91E 1F3FB \; fully-qualified # 🤞 - 🏻 E4.0 crossed fingers: light skin tone\n1F91E 1F3FC - \; fully-qualified # 🤞🏼 E4.0 crossed fingers: mediu - m-light skin tone\n1F91E 1F3FD \; fully-qua - lified # 🤞🏽 E4.0 crossed fingers: medium skin tone\n1F91E 1F3FE - \; fully-qualified # 🤞🏾 E4.0 cros - sed fingers: medium-dark skin tone\n1F91E 1F3FF - \; fully-qualified # 🤞🏿 E4.0 crossed fingers: dark skin ton - e\n1F91F \; fully-qualified # - 🤟 E5.0 love-you gesture\n1F91F 1F3FB \; - fully-qualified # 🤟🏻 E5.0 love-you gesture: light skin tone\n1F9 - 1F 1F3FC \; fully-qualified # 🤟🏼 - E5.0 love-you gesture: medium-light skin tone\n1F91F 1F3FD - \; fully-qualified # 🤟🏽 E5.0 love-you gesture: m - edium skin tone\n1F91F 1F3FE \; fully-quali - fied # 🤟🏾 E5.0 love-you gesture: medium-dark skin tone\n1F91F 1F - 3FF \; fully-qualified # 🤟�� E5. - 0 love-you gesture: dark skin tone\n1F918 - \; fully-qualified # 🤘 E2.0 sign of the horns\n1F918 1F3FB - \; fully-qualified # 🤘🏻 E2.0 sign of - the horns: light skin tone\n1F918 1F3FC \; - fully-qualified # 🤘🏼 E2.0 sign of the horns: medium-light skin - tone\n1F918 1F3FD \; fully-qualified # - 🤘🏽 E2.0 sign of the horns: medium skin tone\n1F918 1F3FE - \; fully-qualified # 🤘🏾 E2.0 sign of the hor - ns: medium-dark skin tone\n1F918 1F3FF \; f - ully-qualified # ��🏿 E2.0 sign of the horns: dark skin tone\n1F - 919 \; fully-qualified # 🤙 E4. - 0 call me hand\n1F919 1F3FB \; fully-qualif - ied # 🤙🏻 E4.0 call me hand: light skin tone\n1F919 1F3FC - \; fully-qualified # 🤙🏼 E4.0 call me han - d: medium-light skin tone\n1F919 1F3FD \; f - ully-qualified # 🤙🏽 E4.0 call me hand: medium skin tone\n1F919 1 - F3FE \; fully-qualified # 🤙🏾 E4.0 - call me hand: medium-dark skin tone\n1F919 1F3FF - \; fully-qualified # 🤙🏿 E4.0 call me hand: dark skin tone - \n\n# subgroup: hand-single-finger\n1F448 - \; fully-qualified # 👈 E2.0 backhand index pointing left\n1F44 - 8 1F3FB \; fully-qualified # 👈🏻 E - 2.0 backhand index pointing left: light skin tone\n1F448 1F3FC - \; fully-qualified # 👈🏼 E2.0 backhand index - pointing left: medium-light skin tone\n1F448 1F3FD - \; fully-qualified # 👈🏽 E2.0 backhand index pointing lef - t: medium skin tone\n1F448 1F3FE \; fully-q - ualified # 👈🏾 E2.0 backhand index pointing left: medium-dark ski - n tone\n1F448 1F3FF \; fully-qualified - # 👈🏿 E2.0 backhand index pointing left: dark skin tone\n1F449 - \; fully-qualified # 👉 E2.0 backhand - index pointing right\n1F449 1F3FB \; fully - -qualified # 👉🏻 E2.0 backhand index pointing right: light skin t - one\n1F449 1F3FC \; fully-qualified # - 👉🏼 E2.0 backhand index pointing right: medium-light skin tone\n1F449 - 1F3FD \; fully-qualified # 👉🏽 E2 - .0 backhand index pointing right: medium skin tone\n1F449 1F3FE - \; fully-qualified # 👉🏾 E2.0 backhand index - pointing right: medium-dark skin tone\n1F449 1F3FF - \; fully-qualified # 👉🏿 E2.0 backhand index pointing ri - ght: dark skin tone\n1F446 \; fully-q - ualified # 👆 E2.0 backhand index pointing up\n1F446 1F3FB - \; fully-qualified # 👆🏻 E2.0 backhand inde - x pointing up: light skin tone\n1F446 1F3FC - \; fully-qualified # 👆🏼 E2.0 backhand index pointing up: medium - -light skin tone\n1F446 1F3FD \; fully-qual - ified # 👆🏽 E2.0 backhand index pointing up: medium skin tone\n1F - 446 1F3FE \; fully-qualified # 👆🏾 - E2.0 backhand index pointing up: medium-dark skin tone\n1F446 1F3FF - \; fully-qualified # 👆🏿 E2.0 backhand - index pointing up: dark skin tone\n1F595 - \; fully-qualified # 🖕 E2.0 middle finger\n1F595 1F3FB - \; fully-qualified # 🖕🏻 E2.0 middle finge - r: light skin tone\n1F595 1F3FC \; fully-qu - alified # 🖕🏼 E2.0 middle finger: medium-light skin tone\n1F595 1 - F3FD \; fully-qualified # 🖕🏽 E2.0 - middle finger: medium skin tone\n1F595 1F3FE - \; fully-qualified # ��🏾 E2.0 middle finger: medium-dark ski - n tone\n1F595 1F3FF \; fully-qualified - # 🖕🏿 E2.0 middle finger: dark skin tone\n1F447 - \; fully-qualified # �� E2.0 backhand index pointi - ng down\n1F447 1F3FB \; fully-qualified - # 👇🏻 E2.0 backhand index pointing down: light skin tone\n1F447 1F3F - C \; fully-qualified # 👇🏼 E2.0 ba - ckhand index pointing down: medium-light skin tone\n1F447 1F3FD - \; fully-qualified # 👇🏽 E2.0 backhand index - pointing down: medium skin tone\n1F447 1F3FE - \; fully-qualified # 👇🏾 E2.0 backhand index pointing down: me - dium-dark skin tone\n1F447 1F3FF \; fully-q - ualified # 👇🏿 E2.0 backhand index pointing down: dark skin tone\ - n261D FE0F \; fully-qualified # ☝ - ️ E2.0 index pointing up\n261D \; - unqualified # ☝ E2.0 index pointing up\n261D 1F3FB - \; fully-qualified # ☝🏻 E2.0 index pointing up - : light skin tone\n261D 1F3FC \; fully-qua - lified # ☝🏼 E2.0 index pointing up: medium-light skin tone\n261D - 1F3FD \; fully-qualified # ☝🏽 E2. - 0 index pointing up: medium skin tone\n261D 1F3FE - \; fully-qualified # ☝🏾 E2.0 index pointing up: medium-da - rk skin tone\n261D 1F3FF \; fully-qualifie - d # ☝🏿 E2.0 index pointing up: dark skin tone\n\n# subgroup: hand - -fingers-closed\n1F44D \; fully-quali - fied # 👍 E2.0 thumbs up\n1F44D 1F3FB - \; fully-qualified # 👍🏻 E2.0 thumbs up: light skin tone\n1F44D - 1F3FC \; fully-qualified # 👍🏼 E2. - 0 thumbs up: medium-light skin tone\n1F44D 1F3FD - \; fully-qualified # 👍🏽 E2.0 thumbs up: medium skin tone\n - 1F44D 1F3FE \; fully-qualified # 👍 - 🏾 E2.0 thumbs up: medium-dark skin tone\n1F44D 1F3FF - \; fully-qualified # 👍🏿 E2.0 thumbs up: dark skin t - one\n1F44E \; fully-qualified # - 👎 E2.0 thumbs down\n1F44E 1F3FB \; fully - -qualified # 👎🏻 E2.0 thumbs down: light skin tone\n1F44E 1F3FC - \; fully-qualified # 👎🏼 E2.0 thumb - s down: medium-light skin tone\n1F44E 1F3FD - \; fully-qualified # 👎🏽 E2.0 thumbs down: medium skin tone\n1F4 - 4E 1F3FE \; fully-qualified # 👎🏾 - E2.0 thumbs down: medium-dark skin tone\n1F44E 1F3FF - \; fully-qualified # 👎🏿 E2.0 thumbs down: dark skin to - ne\n270A \; fully-qualified # - ✊ E2.0 raised fist\n270A 1F3FB \; fully- - qualified # ✊🏻 E2.0 raised fist: light skin tone\n270A 1F3FC - \; fully-qualified # ✊🏼 E2.0 raised f - ist: medium-light skin tone\n270A 1F3FD \; - fully-qualified # ✊🏽 E2.0 raised fist: medium skin tone\n270A 1F - 3FE \; fully-qualified # ✊🏾 E2.0 - raised fist: medium-dark skin tone\n270A 1F3FF - \; fully-qualified # ✊🏿 E2.0 raised fist: dark skin tone\n1F - 44A \; fully-qualified # 👊 E2. - 0 oncoming fist\n1F44A 1F3FB \; fully-quali - fied # 👊🏻 E2.0 oncoming fist: light skin tone\n1F44A 1F3FC - \; fully-qualified # 👊🏼 E2.0 oncoming - fist: medium-light skin tone\n1F44A 1F3FD \ - ; fully-qualified # 👊🏽 E2.0 oncoming fist: medium skin tone\n1F4 - 4A 1F3FE \; fully-qualified # 👊🏾 - E2.0 oncoming fist: medium-dark skin tone\n1F44A 1F3FF - \; fully-qualified # 👊🏿 E2.0 oncoming fist: dark ski - n tone\n1F91B \; fully-qualified - # 🤛 E4.0 left-facing fist\n1F91B 1F3FB \ - ; fully-qualified # 🤛🏻 E4.0 left-facing fist: light skin tone\n1 - F91B 1F3FC \; fully-qualified # 🤛 - 🏼 E4.0 left-facing fist: medium-light skin tone\n1F91B 1F3FD - \; fully-qualified # 🤛🏽 E4.0 left-facing fi - st: medium skin tone\n1F91B 1F3FE \; fully- - qualified # 🤛🏾 E4.0 left-facing fist: medium-dark skin tone\n1F9 - 1B 1F3FF \; fully-qualified # 🤛🏿 - E4.0 left-facing fist: dark skin tone\n1F91C - \; fully-qualified # 🤜 E4.0 right-facing fist\n1F91C 1F3FB - \; fully-qualified # ��🏻 E4.0 ri - ght-facing fist: light skin tone\n1F91C 1F3FC - \; fully-qualified # 🤜🏼 E4.0 right-facing fist: medium-light - skin tone\n1F91C 1F3FD \; fully-qualified - # 🤜🏽 E4.0 right-facing fist: medium skin tone\n1F91C 1F3FE - \; fully-qualified # 🤜🏾 E4.0 right-faci - ng fist: medium-dark skin tone\n1F91C 1F3FF - \; fully-qualified # 🤜🏿 E4.0 right-facing fist: dark skin tone\ - n\n# subgroup: hands\n1F44F \; fully- - qualified # 👏 E2.0 clapping hands\n1F44F 1F3FB - \; fully-qualified # 👏🏻 E2.0 clapping hands: light sk - in tone\n1F44F 1F3FC \; fully-qualified - # 👏🏼 E2.0 clapping hands: medium-light skin tone\n1F44F 1F3FD - \; fully-qualified # 👏🏽 E2.0 clapping - hands: medium skin tone\n1F44F 1F3FE \; ful - ly-qualified # 👏🏾 E2.0 clapping hands: medium-dark skin tone\n1F - 44F 1F3FF \; fully-qualified # 👏🏿 - E2.0 clapping hands: dark skin tone\n1F64C - \; fully-qualified # 🙌 E2.0 raising hands\n1F64C 1F3FB - \; fully-qualified # 🙌🏻 E2.0 raising h - ands: light skin tone\n1F64C 1F3FC \; fully - -qualified # 🙌🏼 E2.0 raising hands: medium-light skin tone\n1F64 - C 1F3FD \; fully-qualified # 🙌🏽 E - 2.0 raising hands: medium skin tone\n1F64C 1F3FE - \; fully-qualified # 🙌🏾 E2.0 raising hands: medium-dark sk - in tone\n1F64C 1F3FF \; fully-qualified - # 🙌🏿 E2.0 raising hands: dark skin tone\n1F450 - \; fully-qualified # 👐 E2.0 open hands\n1F450 1F3F - B \; fully-qualified # 👐🏻 E2.0 op - en hands: light skin tone\n1F450 1F3FC \; f - ully-qualified # 👐🏼 E2.0 open hands: medium-light skin tone\n1F4 - 50 1F3FD \; fully-qualified # 👐🏽 - E2.0 open hands: medium skin tone\n1F450 1F3FE - \; fully-qualified # 👐🏾 E2.0 open hands: medium-dark skin to - ne\n1F450 1F3FF \; fully-qualified # - 👐🏿 E2.0 open hands: dark skin tone\n1F932 - \; fully-qualified # 🤲 E5.0 palms up together\n1F932 1F3 - FB \; fully-qualified # 🤲🏻 E5.0 p - alms up together: light skin tone\n1F932 1F3FC - \; fully-qualified # 🤲🏼 E5.0 palms up together: medium-light - skin tone\n1F932 1F3FD \; fully-qualified - # 🤲🏽 E5.0 palms up together: medium skin tone\n1F932 1F3FE - \; fully-qualified # 🤲🏾 E5.0 palms up - together: medium-dark skin tone\n1F932 1F3FF - \; fully-qualified # 🤲🏿 E5.0 palms up together: dark skin tone - \n1F91D \; fully-qualified # 🤝 - E4.0 handshake\n1F64F \; fully-quali - fied # 🙏 E2.0 folded hands\n1F64F 1F3FB - \; fully-qualified # 🙏🏻 E2.0 folded hands: light skin tone\n - 1F64F 1F3FC \; fully-qualified # 🙏 - 🏼 E2.0 folded hands: medium-light skin tone\n1F64F 1F3FD - \; fully-qualified # 🙏🏽 E2.0 folded hands: medi - um skin tone\n1F64F 1F3FE \; fully-qualifie - d # 🙏🏾 E2.0 folded hands: medium-dark skin tone\n1F64F 1F3FF - \; fully-qualified # 🙏🏿 E2.0 folded - hands: dark skin tone\n\n# subgroup: hand-prop\n270D FE0F - \; fully-qualified # ✍️ E2.0 writing hand\n270D - \; unqualified # ✍ E2.0 wri - ting hand\n270D 1F3FB \; fully-qualified - # ✍🏻 E2.0 writing hand: light skin tone\n270D 1F3FC - \; fully-qualified # ✍🏼 E2.0 writing hand: medi - um-light skin tone\n270D 1F3FD \; fully-qu - alified # ✍🏽 E2.0 writing hand: medium skin tone\n270D 1F3FE - \; fully-qualified # ✍🏾 E2.0 writing - hand: medium-dark skin tone\n270D 1F3FF \; - fully-qualified # ✍🏿 E2.0 writing hand: dark skin tone\n1F485 - \; fully-qualified # 💅 E2.0 nail - polish\n1F485 1F3FB \; fully-qualified - # 💅🏻 E2.0 nail polish: light skin tone\n1F485 1F3FC - \; fully-qualified # 💅🏼 E2.0 nail polish: medium - -light skin tone\n1F485 1F3FD \; fully-qual - ified # 💅🏽 E2.0 nail polish: medium skin tone\n1F485 1F3FE - \; fully-qualified # 💅🏾 E2.0 nail poli - sh: medium-dark skin tone\n1F485 1F3FF \; f - ully-qualified # 💅🏿 E2.0 nail polish: dark skin tone\n1F933 - \; fully-qualified # 🤳 E4.0 selfie - \n1F933 1F3FB \; fully-qualified # 🤳 - 🏻 E4.0 selfie: light skin tone\n1F933 1F3FC - \; fully-qualified # 🤳🏼 E4.0 selfie: medium-light skin tone\ - n1F933 1F3FD \; fully-qualified # 🤳 - 🏽 E4.0 selfie: medium skin tone\n1F933 1F3FE - \; fully-qualified # 🤳🏾 E4.0 selfie: medium-dark skin tone\ - n1F933 1F3FF \; fully-qualified # 🤳 - 🏿 E4.0 selfie: dark skin tone\n\n# subgroup: body-parts\n1F4AA - \; fully-qualified # 💪 E2.0 flexed bic - eps\n1F4AA 1F3FB \; fully-qualified # - 💪🏻 E2.0 flexed biceps: light skin tone\n1F4AA 1F3FC - \; fully-qualified # 💪🏼 E2.0 flexed biceps: mediu - m-light skin tone\n1F4AA 1F3FD \; fully-qua - lified # 💪🏽 E2.0 flexed biceps: medium skin tone\n1F4AA 1F3FE - \; fully-qualified # 💪🏾 E2.0 flexed - biceps: medium-dark skin tone\n1F4AA 1F3FF - \; fully-qualified # 💪🏿 E2.0 flexed biceps: dark skin tone\n1F9 - BE \; fully-qualified # 🦾 E12. - 1 mechanical arm\n1F9BF \; fully-qual - ified # 🦿 E12.1 mechanical leg\n1F9B5 - \; fully-qualified # 🦵 E11.0 leg\n1F9B5 1F3FB - \; fully-qualified # 🦵🏻 E11.0 leg: light skin - tone\n1F9B5 1F3FC \; fully-qualified # - 🦵🏼 E11.0 leg: medium-light skin tone\n1F9B5 1F3FD - \; fully-qualified # 🦵🏽 E11.0 leg: medium skin tone - \n1F9B5 1F3FE \; fully-qualified # 🦵 - 🏾 E11.0 leg: medium-dark skin tone\n1F9B5 1F3FF - \; fully-qualified # 🦵🏿 E11.0 leg: dark skin tone\n1F9B6 - \; fully-qualified # 🦶 E11.0 - foot\n1F9B6 1F3FB \; fully-qualified # - 🦶🏻 E11.0 foot: light skin tone\n1F9B6 1F3FC - \; fully-qualified # 🦶🏼 E11.0 foot: medium-light skin ton - e\n1F9B6 1F3FD \; fully-qualified # - 🦶🏽 E11.0 foot: medium skin tone\n1F9B6 1F3FE - \; fully-qualified # 🦶🏾 E11.0 foot: medium-dark skin ton - e\n1F9B6 1F3FF \; fully-qualified # - 🦶🏿 E11.0 foot: dark skin tone\n1F442 - \; fully-qualified # 👂 E2.0 ear\n1F442 1F3FB - \; fully-qualified # 👂🏻 E2.0 ear: light skin tone - \n1F442 1F3FC \; fully-qualified # 👂 - 🏼 E2.0 ear: medium-light skin tone\n1F442 1F3FD - \; fully-qualified # 👂🏽 E2.0 ear: medium skin tone\n1F44 - 2 1F3FE \; fully-qualified # 👂🏾 E - 2.0 ear: medium-dark skin tone\n1F442 1F3FF - \; fully-qualified # 👂🏿 E2.0 ear: dark skin tone\n1F9BB - \; fully-qualified # 🦻 E12.1 ear with - hearing aid\n1F9BB 1F3FB \; fully-qualifie - d # 🦻🏻 E12.1 ear with hearing aid: light skin tone\n1F9BB 1F3FC - \; fully-qualified # 🦻🏼 E12.1 ear - with hearing aid: medium-light skin tone\n1F9BB 1F3FD - \; fully-qualified # 🦻🏽 E12.1 ear with hearing aid: - medium skin tone\n1F9BB 1F3FE \; fully-qual - ified # 🦻🏾 E12.1 ear with hearing aid: medium-dark skin tone\n1F - 9BB 1F3FF \; fully-qualified # 🦻🏿 - E12.1 ear with hearing aid: dark skin tone\n1F443 - \; fully-qualified # 👃 E2.0 nose\n1F443 1F3FB - \; fully-qualified # 👃🏻 E2.0 nose: light - skin tone\n1F443 1F3FC \; fully-qualified - # 👃🏼 E2.0 nose: medium-light skin tone\n1F443 1F3FD - \; fully-qualified # 👃🏽 E2.0 nose: medium ski - n tone\n1F443 1F3FE \; fully-qualified - # 👃🏾 E2.0 nose: medium-dark skin tone\n1F443 1F3FF - \; fully-qualified # 👃🏿 E2.0 nose: dark skin tone\ - n1F9E0 \; fully-qualified # 🧠 - E5.0 brain\n1F9B7 \; fully-qualified - # 🦷 E11.0 tooth\n1F9B4 \; full - y-qualified # 🦴 E11.0 bone\n1F440 - \; fully-qualified # 👀 E2.0 eyes\n1F441 FE0F - \; fully-qualified # 👁️ E2.0 eye\n1F441 - \; unqualified # 👁 E2.0 eye\n1F445 - \; fully-qualified # 👅 E2.0 tong - ue\n1F444 \; fully-qualified # - 👄 E2.0 mouth\n\n# subgroup: person\n1F476 - \; fully-qualified # 👶 E2.0 baby\n1F476 1F3FB - \; fully-qualified # 👶🏻 E2.0 baby: light skin - tone\n1F476 1F3FC \; fully-qualified # - 👶🏼 E2.0 baby: medium-light skin tone\n1F476 1F3FD - \; fully-qualified # 👶🏽 E2.0 baby: medium skin tone - \n1F476 1F3FE \; fully-qualified # 👶 - �� E2.0 baby: medium-dark skin tone\n1F476 1F3FF - \; fully-qualified # 👶🏿 E2.0 baby: dark skin tone\n1F9 - D2 \; fully-qualified # 🧒 E5.0 - child\n1F9D2 1F3FB \; fully-qualified - # 🧒🏻 E5.0 child: light skin tone\n1F9D2 1F3FC - \; fully-qualified # 🧒🏼 E5.0 child: medium-light skin t - one\n1F9D2 1F3FD \; fully-qualified # - 🧒🏽 E5.0 child: medium skin tone\n1F9D2 1F3FE - \; fully-qualified # 🧒🏾 E5.0 child: medium-dark skin ton - e\n1F9D2 1F3FF \; fully-qualified # - 🧒🏿 E5.0 child: dark skin tone\n1F466 - \; fully-qualified # 👦 E2.0 boy\n1F466 1F3FB - \; fully-qualified # 👦🏻 E2.0 boy: light skin tone - \n1F466 1F3FC \; fully-qualified # 👦 - 🏼 E2.0 boy: medium-light skin tone\n1F466 1F3FD - \; fully-qualified # 👦🏽 E2.0 boy: medium skin tone\n1F46 - 6 1F3FE \; fully-qualified # ��🏾 - E2.0 boy: medium-dark skin tone\n1F466 1F3FF - \; fully-qualified # 👦🏿 E2.0 boy: dark skin tone\n1F467 - \; fully-qualified # 👧 E2.0 girl\n1 - F467 1F3FB \; fully-qualified # 👧 - 🏻 E2.0 girl: light skin tone\n1F467 1F3FC - \; fully-qualified # 👧🏼 E2.0 girl: medium-light skin tone\n1F4 - 67 1F3FD \; fully-qualified # 👧🏽 - E2.0 girl: medium skin tone\n1F467 1F3FE \; - fully-qualified # 👧�� E2.0 girl: medium-dark skin tone\n1F467 - 1F3FF \; fully-qualified # 👧🏿 E2. - 0 girl: dark skin tone\n1F9D1 \; full - y-qualified # 🧑 E5.0 person\n1F9D1 1F3FB - \; fully-qualified # 🧑🏻 E5.0 person: light skin tone\n1F9D1 - 1F3FC \; fully-qualified # 🧑🏼 E5 - .0 person: medium-light skin tone\n1F9D1 1F3FD - \; fully-qualified # 🧑🏽 E5.0 person: medium skin tone\n1F9D1 - 1F3FE \; fully-qualified # 🧑🏾 E5 - .0 person: medium-dark skin tone\n1F9D1 1F3FF - \; fully-qualified # 🧑🏿 E5.0 person: dark skin tone\n1F471 - \; fully-qualified # 👱 E2.0 pers - on: blond hair\n1F471 1F3FB \; fully-qualif - ied # 👱🏻 E2.0 person: light skin tone\, blond hair\n1F471 1F3FC - \; fully-qualified # 👱🏼 E2.0 pers - on: medium-light skin tone\, blond hair\n1F471 1F3FD - \; fully-qualified # 👱🏽 E2.0 person: medium skin tone\ - , blond hair\n1F471 1F3FE \; fully-qualifie - d # 👱🏾 E2.0 person: medium-dark skin tone\, blond hair\n1F471 1F - 3FF \; fully-qualified # 👱🏿 E2.0 - person: dark skin tone\, blond hair\n1F468 - \; fully-qualified # 👨 E2.0 man\n1F468 1F3FB - \; fully-qualified # 👨🏻 E2.0 man: light skin tone - \n1F468 1F3FC \; fully-qualified # 👨 - 🏼 E2.0 man: medium-light skin tone\n1F468 1F3FD - \; fully-qualified # 👨🏽 E2.0 man: medium skin tone\n1F46 - 8 1F3FE \; fully-qualified # 👨🏾 E - 2.0 man: medium-dark skin tone\n1F468 1F3FF - \; fully-qualified # 👨🏿 E2.0 man: dark skin tone\n1F9D4 - \; fully-qualified # �� E5.0 man: be - ard\n1F9D4 1F3FB \; fully-qualified # - 🧔🏻 E5.0 man: light skin tone\, beard\n1F9D4 1F3FC - \; fully-qualified # 🧔🏼 E5.0 man: medium-light skin - tone\, beard\n1F9D4 1F3FD \; fully-qualifi - ed # 🧔🏽 E5.0 man: medium skin tone\, beard\n1F9D4 1F3FE - \; fully-qualified # 🧔🏾 E5.0 man: medium- - dark skin tone\, beard\n1F9D4 1F3FF \; full - y-qualified # 🧔🏿 E5.0 man: dark skin tone\, beard\n1F468 200D 1F - 9B0 \; fully-qualified # 👨‍🦰 E11.0 m - an: red hair\n1F468 1F3FB 200D 1F9B0 \; fully-qualifie - d # 👨🏻‍🦰 E11.0 man: light skin tone\, red hair\n1F468 1F3FC - 200D 1F9B0 \; fully-qualified # 👨🏼‍🦰 E - 11.0 man: medium-light skin tone\, red hair\n1F468 1F3FD 200D 1F9B0 - \; fully-qualified # 👨��‍🦰 E11.0 man: medium - skin tone\, red hair\n1F468 1F3FE 200D 1F9B0 \; fully - -qualified # 👨🏾‍🦰 E11.0 man: medium-dark skin tone\, red ha - ir\n1F468 1F3FF 200D 1F9B0 \; fully-qualified # - 👨🏿‍🦰 E11.0 man: dark skin tone\, red hair\n1F468 200D 1F9B1 - \; fully-qualified # 👨‍🦱 E11.0 man: cur - ly hair\n1F468 1F3FB 200D 1F9B1 \; fully-qualified - # 👨🏻‍🦱 E11.0 man: light skin tone\, curly hair\n1F468 1F3FC 20 - 0D 1F9B1 \; fully-qualified # 👨🏼‍🦱 E11. - 0 man: medium-light skin tone\, curly hair\n1F468 1F3FD 200D 1F9B1 - \; fully-qualified # 👨🏽‍🦱 E11.0 man: medium sk - in tone\, curly hair\n1F468 1F3FE 200D 1F9B1 \; fully- - qualified # 👨🏾‍🦱 E11.0 man: medium-dark skin tone\, curly h - air\n1F468 1F3FF 200D 1F9B1 \; fully-qualified # - 👨🏿‍🦱 E11.0 man: dark skin tone\, curly hair\n1F468 200D 1F9B3 - \; fully-qualified # 👨‍🦳 E11.0 man: w - hite hair\n1F468 1F3FB 200D 1F9B3 \; fully-qualified - # 👨🏻‍🦳 E11.0 man: light skin tone\, white hair\n1F468 1F3FC - 200D 1F9B3 \; fully-qualified # 👨🏼‍🦳 E1 - 1.0 man: medium-light skin tone\, white hair\n1F468 1F3FD 200D 1F9B3 - \; fully-qualified # 👨🏽‍🦳 E11.0 man: medium - skin tone\, white hair\n1F468 1F3FE 200D 1F9B3 \; full - y-qualified # ��🏾‍🦳 E11.0 man: medium-dark skin tone\, whi - te hair\n1F468 1F3FF 200D 1F9B3 \; fully-qualified - # 👨🏿‍🦳 E11.0 man: dark skin tone\, white hair\n1F468 200D 1F9B - 2 \; fully-qualified # 👨‍🦲 E11.0 man - : bald\n1F468 1F3FB 200D 1F9B2 \; fully-qualified - # 👨🏻‍🦲 E11.0 man: light skin tone\, bald\n1F468 1F3FC 200D 1F9B - 2 \; fully-qualified # 👨🏼‍🦲 E11.0 man: - medium-light skin tone\, bald\n1F468 1F3FD 200D 1F9B2 - \; fully-qualified # 👨🏽‍🦲 E11.0 man: medium skin tone\, bal - d\n1F468 1F3FE 200D 1F9B2 \; fully-qualified # - 👨🏾‍🦲 E11.0 man: medium-dark skin tone\, bald\n1F468 1F3FF 200D - 1F9B2 \; fully-qualified # 👨🏿‍🦲 E11.0 m - an: dark skin tone\, bald\n1F469 \; f - ully-qualified # 👩 E2.0 woman\n1F469 1F3FB - \; fully-qualified # 👩🏻 E2.0 woman: light skin tone\n1F46 - 9 1F3FC \; fully-qualified # 👩🏼 E - 2.0 woman: medium-light skin tone\n1F469 1F3FD - \; fully-qualified # 👩🏽 E2.0 woman: medium skin tone\n1F469 - 1F3FE \; fully-qualified # 👩🏾 E2. - 0 woman: medium-dark skin tone\n1F469 1F3FF - \; fully-qualified # 👩🏿 E2.0 woman: dark skin tone\n1F469 200D - 1F9B0 \; fully-qualified # 👩‍🦰 E11.0 - woman: red hair\n1F469 1F3FB 200D 1F9B0 \; fully-qual - ified # 👩🏻‍🦰 E11.0 woman: light skin tone\, red hair\n1F469 - 1F3FC 200D 1F9B0 \; fully-qualified # 👩🏼‍ - 🦰 E11.0 woman: medium-light skin tone\, red hair\n1F469 1F3FD 200D 1F9B - 0 \; fully-qualified # 👩🏽‍🦰 E11.0 woman - : medium skin tone\, red hair\n1F469 1F3FE 200D 1F9B0 - \; fully-qualified # 👩🏾‍🦰 E11.0 woman: medium-dark skin ton - e\, red hair\n1F469 1F3FF 200D 1F9B0 \; fully-qualifie - d # 👩🏿‍🦰 E11.0 woman: dark skin tone\, red hair\n1F9D1 200D - 1F9B0 \; fully-qualified # 🧑‍🦰 E12. - 1 person: red hair\n1F9D1 1F3FB 200D 1F9B0 \; fully-qu - alified # 🧑🏻‍🦰 E12.1 person: light skin tone\, red hair\n1F - 9D1 1F3FC 200D 1F9B0 \; fully-qualified # 🧑🏼 - ‍🦰 E12.1 person: medium-light skin tone\, red hair\n1F9D1 1F3FD 200D - 1F9B0 \; fully-qualified # 🧑🏽‍🦰 E12.1 p - erson: medium skin tone\, red hair\n1F9D1 1F3FE 200D 1F9B0 - \; fully-qualified # 🧑🏾‍🦰 E12.1 person: medium-dark sk - in tone\, red hair\n1F9D1 1F3FF 200D 1F9B0 \; fully-qu - alified # 🧑🏿‍🦰 E12.1 person: dark skin tone\, red hair\n1F4 - 69 200D 1F9B1 \; fully-qualified # 👩‍ - 🦱 E11.0 woman: curly hair\n1F469 1F3FB 200D 1F9B1 \ - ; fully-qualified # 👩🏻‍🦱 E11.0 woman: light skin tone\, cur - ly hair\n1F469 1F3FC 200D 1F9B1 \; fully-qualified - # 👩🏼‍🦱 E11.0 woman: medium-light skin tone\, curly hair\n1F469 - 1F3FD 200D 1F9B1 \; fully-qualified # 👩🏽‍ - 🦱 E11.0 woman: medium skin tone\, curly hair\n1F469 1F3FE 200D 1F9B1 - \; fully-qualified # 👩🏾‍🦱 E11.0 woman: me - dium-dark skin tone\, curly hair\n1F469 1F3FF 200D 1F9B1 - \; fully-qualified # 👩🏿‍🦱 E11.0 woman: dark skin tone\, - curly hair\n1F9D1 200D 1F9B1 \; fully-qualified - # 🧑‍🦱 E12.1 person: curly hair\n1F9D1 1F3FB 200D 1F9B1 - \; fully-qualified # 🧑🏻‍🦱 E12.1 person: light - skin tone\, curly hair\n1F9D1 1F3FC 200D 1F9B1 \; full - y-qualified # 🧑🏼‍🦱 E12.1 person: medium-light skin tone\, c - urly hair\n1F9D1 1F3FD 200D 1F9B1 \; fully-qualified - # 🧑🏽‍🦱 E12.1 person: medium skin tone\, curly hair\n1F9D1 1F - 3FE 200D 1F9B1 \; fully-qualified # 🧑🏾‍ - 🦱 E12.1 person: medium-dark skin tone\, curly hair\n1F9D1 1F3FF 200D 1F - 9B1 \; fully-qualified # 🧑🏿‍🦱 E12.1 per - son: dark skin tone\, curly hair\n1F469 200D 1F9B3 - \; fully-qualified # 👩‍🦳 E11.0 woman: white hair\n1F469 1F3 - FB 200D 1F9B3 \; fully-qualified # 👩🏻‍🦳 - E11.0 woman: light skin tone\, white hair\n1F469 1F3FC 200D 1F9B3 - \; fully-qualified # 👩🏼‍🦳 E11.0 woman: medium- - light skin tone\, white hair\n1F469 1F3FD 200D 1F9B3 \ - ; fully-qualified # 👩🏽‍🦳 E11.0 woman: medium skin tone\, wh - ite hair\n1F469 1F3FE 200D 1F9B3 \; fully-qualified - # 👩🏾‍🦳 E11.0 woman: medium-dark skin tone\, white hair\n1F469 - 1F3FF 200D 1F9B3 \; fully-qualified # 👩🏿‍ - 🦳 E11.0 woman: dark skin tone\, white hair\n1F9D1 200D 1F9B3 - \; fully-qualified # 🧑‍🦳 E12.1 person: white h - air\n1F9D1 1F3FB 200D 1F9B3 \; fully-qualified # - 🧑🏻‍🦳 E12.1 person: light skin tone\, white hair\n1F9D1 1F3FC 20 - 0D 1F9B3 \; fully-qualified # 🧑🏼‍🦳 E12. - 1 person: medium-light skin tone\, white hair\n1F9D1 1F3FD 200D 1F9B3 - \; fully-qualified # 🧑🏽‍🦳 E12.1 person: med - ium skin tone\, white hair\n1F9D1 1F3FE 200D 1F9B3 \; - fully-qualified # 🧑🏾‍🦳 E12.1 person: medium-dark skin tone\ - , white hair\n1F9D1 1F3FF 200D 1F9B3 \; fully-qualifie - d # 🧑🏿‍🦳 E12.1 person: dark skin tone\, white hair\n1F469 2 - 00D 1F9B2 \; fully-qualified # 👩‍🦲 E - 11.0 woman: bald\n1F469 1F3FB 200D 1F9B2 \; fully-qual - ified # 👩🏻‍🦲 E11.0 woman: light skin tone\, bald\n1F469 1F3 - FC 200D 1F9B2 \; fully-qualified # 👩🏼‍🦲 - E11.0 woman: medium-light skin tone\, bald\n1F469 1F3FD 200D 1F9B2 - \; fully-qualified # 👩🏽‍🦲 E11.0 woman: medium - skin tone\, bald\n1F469 1F3FE 200D 1F9B2 \; fully-qua - lified # 👩🏾‍🦲 E11.0 woman: medium-dark skin tone\, bald\n1F - 469 1F3FF 200D 1F9B2 \; fully-qualified # 👩🏿 - ‍🦲 E11.0 woman: dark skin tone\, bald\n1F9D1 200D 1F9B2 - \; fully-qualified # 🧑‍🦲 E12.1 person: bald\n1F9D - 1 1F3FB 200D 1F9B2 \; fully-qualified # 🧑🏻 - ‍🦲 E12.1 person: light skin tone\, bald\n1F9D1 1F3FC 200D 1F9B2 - \; fully-qualified # 🧑🏼‍🦲 E12.1 person: medi - um-light skin tone\, bald\n1F9D1 1F3FD 200D 1F9B2 \; f - ully-qualified # 🧑🏽‍🦲 E12.1 person: medium skin tone\, bald - \n1F9D1 1F3FE 200D 1F9B2 \; fully-qualified # 🧑 - 🏾‍🦲 E12.1 person: medium-dark skin tone\, bald\n1F9D1 1F3FF 200D 1 - F9B2 \; fully-qualified # ��🏿‍🦲 E12.1 - person: dark skin tone\, bald\n1F471 200D 2640 FE0F - \; fully-qualified # 👱‍♀️ E4.0 woman: blond hair\n1F471 200D - 2640 \; minimally-qualified # 👱‍♀ E4.0 w - oman: blond hair\n1F471 1F3FB 200D 2640 FE0F \; fully-qual - ified # 👱🏻‍♀️ E4.0 woman: light skin tone\, blond hair\n1F - 471 1F3FB 200D 2640 \; minimally-qualified # 👱🏻 - ‍♀ E4.0 woman: light skin tone\, blond hair\n1F471 1F3FC 200D 2640 FE0 - F \; fully-qualified # 👱🏼‍♀️ E4.0 woman: m - edium-light skin tone\, blond hair\n1F471 1F3FC 200D 2640 - \; minimally-qualified # 👱🏼‍♀ E4.0 woman: medium-light skin - tone\, blond hair\n1F471 1F3FD 200D 2640 FE0F \; fully-qu - alified # 👱🏽‍♀️ E4.0 woman: medium skin tone\, blond hair\ - n1F471 1F3FD 200D 2640 \; minimally-qualified # 👱 - 🏽‍♀ E4.0 woman: medium skin tone\, blond hair\n1F471 1F3FE 200D 264 - 0 FE0F \; fully-qualified # 👱🏾‍♀️ E4.0 wom - an: medium-dark skin tone\, blond hair\n1F471 1F3FE 200D 2640 - \; minimally-qualified # 👱🏾‍♀ E4.0 woman: medium-dark s - kin tone\, blond hair\n1F471 1F3FF 200D 2640 FE0F \; fully - -qualified # 👱🏿‍♀️ E4.0 woman: dark skin tone\, blond hair - \n1F471 1F3FF 200D 2640 \; minimally-qualified # 👱 - 🏿‍♀ E4.0 woman: dark skin tone\, blond hair\n1F471 200D 2642 FE0F - \; fully-qualified # 👱‍♂️ E4.0 man: blon - d hair\n1F471 200D 2642 \; minimally-qualified - # 👱‍♂ E4.0 man: blond hair\n1F471 1F3FB 200D 2642 FE0F - \; fully-qualified # 👱🏻‍♂️ E4.0 man: light skin tone\, - blond hair\n1F471 1F3FB 200D 2642 \; minimally-quali - fied # 👱🏻‍♂ E4.0 man: light skin tone\, blond hair\n1F471 1F3FC - 200D 2642 FE0F \; fully-qualified # 👱🏼‍♂️ - E4.0 man: medium-light skin tone\, blond hair\n1F471 1F3FC 200D 2642 - \; minimally-qualified # 👱🏼‍♂ E4.0 man: medium-l - ight skin tone\, blond hair\n1F471 1F3FD 200D 2642 FE0F \; - fully-qualified # 👱🏽‍♂️ E4.0 man: medium skin tone\, blon - d hair\n1F471 1F3FD 200D 2642 \; minimally-qualified - # 👱🏽‍♂ E4.0 man: medium skin tone\, blond hair\n1F471 1F3FE 200D - 2642 FE0F \; fully-qualified # 👱🏾‍♂️ E4.0 - man: medium-dark skin tone\, blond hair\n1F471 1F3FE 200D 2642 - \; minimally-qualified # 👱🏾‍♂ E4.0 man: medium-dark s - kin tone\, blond hair\n1F471 1F3FF 200D 2642 FE0F \; fully - -qualified # 👱🏿‍♂️ E4.0 man: dark skin tone\, blond hair\n - 1F471 1F3FF 200D 2642 \; minimally-qualified # 👱 - 🏿‍♂ E4.0 man: dark skin tone\, blond hair\n1F9D3 - \; fully-qualified # 🧓 E5.0 older person\n1F9D3 - 1F3FB \; fully-qualified # 🧓🏻 E5. - 0 older person: light skin tone\n1F9D3 1F3FC - \; fully-qualified # 🧓🏼 E5.0 older person: medium-light skin t - one\n1F9D3 1F3FD \; fully-qualified # - 🧓🏽 E5.0 older person: medium skin tone\n1F9D3 1F3FE - \; fully-qualified # 🧓🏾 E5.0 older person: medium - -dark skin tone\n1F9D3 1F3FF \; fully-quali - fied # 🧓🏿 E5.0 older person: dark skin tone\n1F474 - \; fully-qualified # 👴 E2.0 old man\n1F474 - 1F3FB \; fully-qualified # 👴🏻 E2. - 0 old man: light skin tone\n1F474 1F3FC \; - fully-qualified # 👴🏼 E2.0 old man: medium-light skin tone\n1F474 - 1F3FD \; fully-qualified # 👴🏽 E2 - .0 old man: medium skin tone\n1F474 1F3FE \ - ; fully-qualified # 👴🏾 E2.0 old man: medium-dark skin tone\n1F47 - 4 1F3FF \; fully-qualified # 👴🏿 E - 2.0 old man: dark skin tone\n1F475 \; - fully-qualified # 👵 E2.0 old woman\n1F475 1F3FB - \; fully-qualified # 👵🏻 E2.0 old woman: light skin - tone\n1F475 1F3FC \; fully-qualified # - 👵🏼 E2.0 old woman: medium-light skin tone\n1F475 1F3FD - \; fully-qualified # 👵🏽 E2.0 old woman: medium - skin tone\n1F475 1F3FE \; fully-qualified - # 👵🏾 E2.0 old woman: medium-dark skin tone\n1F475 1F3FF - \; fully-qualified # 👵🏿 E2.0 old woman: d - ark skin tone\n\n# subgroup: person-gesture\n1F64D - \; fully-qualified # 🙍 E2.0 person frowning\n1F64D 1F - 3FB \; fully-qualified # 🙍🏻 E2.0 - person frowning: light skin tone\n1F64D 1F3FC - \; fully-qualified # 🙍🏼 E2.0 person frowning: medium-light sk - in tone\n1F64D 1F3FD \; fully-qualified - # 🙍🏽 E2.0 person frowning: medium skin tone\n1F64D 1F3FE - \; fully-qualified # 🙍🏾 E2.0 person frownin - g: medium-dark skin tone\n1F64D 1F3FF \; fu - lly-qualified # 🙍🏿 E2.0 person frowning: dark skin tone\n1F64D 2 - 00D 2642 FE0F \; fully-qualified # 🙍‍♂️ - E4.0 man frowning\n1F64D 200D 2642 \; minimall - y-qualified # 🙍‍♂ E4.0 man frowning\n1F64D 1F3FB 200D 2642 FE0F - \; fully-qualified # 🙍🏻‍♂️ E4.0 man frowning: - light skin tone\n1F64D 1F3FB 200D 2642 \; minimally- - qualified # 🙍🏻‍♂ E4.0 man frowning: light skin tone\n1F64D 1F3FC - 200D 2642 FE0F \; fully-qualified # 🙍🏼‍♂️ - E4.0 man frowning: medium-light skin tone\n1F64D 1F3FC 200D 2642 - \; minimally-qualified # ��🏼‍♂ E4.0 man frowning: - medium-light skin tone\n1F64D 1F3FD 200D 2642 FE0F \; full - y-qualified # 🙍🏽‍♂️ E4.0 man frowning: medium skin tone\n1 - F64D 1F3FD 200D 2642 \; minimally-qualified # 🙍 - 🏽‍♂ E4.0 man frowning: medium skin tone\n1F64D 1F3FE 200D 2642 FE0F - \; fully-qualified # 🙍🏾‍♂️ E4.0 man frown - ing: medium-dark skin tone\n1F64D 1F3FE 200D 2642 \; - minimally-qualified # 🙍🏾‍♂ E4.0 man frowning: medium-dark skin t - one\n1F64D 1F3FF 200D 2642 FE0F \; fully-qualified # - 🙍🏿‍♂️ E4.0 man frowning: dark skin tone\n1F64D 1F3FF 200D 2642 - \; minimally-qualified # 🙍🏿‍♂ E4.0 man fro - wning: dark skin tone\n1F64D 200D 2640 FE0F \; fully - -qualified # 🙍‍♀️ E4.0 woman frowning\n1F64D 200D 2640 - \; minimally-qualified # 🙍‍♀ E4.0 woman frowni - ng\n1F64D 1F3FB 200D 2640 FE0F \; fully-qualified # - 🙍🏻‍♀️ E4.0 woman frowning: light skin tone\n1F64D 1F3FB 200D 2 - 640 \; minimally-qualified # 🙍🏻‍♀ E4.0 woma - n frowning: light skin tone\n1F64D 1F3FC 200D 2640 FE0F \; - fully-qualified # 🙍🏼‍♀️ E4.0 woman frowning: medium-light - skin tone\n1F64D 1F3FC 200D 2640 \; minimally-qualif - ied # 🙍🏼‍♀ E4.0 woman frowning: medium-light skin tone\n1F64D 1F - 3FD 200D 2640 FE0F \; fully-qualified # 🙍🏽‍♀ - ️ E4.0 woman frowning: medium skin tone\n1F64D 1F3FD 200D 2640 - \; minimally-qualified # 🙍🏽‍♀ E4.0 woman frowning: m - edium skin tone\n1F64D 1F3FE 200D 2640 FE0F \; fully-quali - fied # 🙍🏾‍♀️ E4.0 woman frowning: medium-dark skin tone\n1 - F64D 1F3FE 200D 2640 \; minimally-qualified # 🙍 - 🏾‍♀ E4.0 woman frowning: medium-dark skin tone\n1F64D 1F3FF 200D 26 - 40 FE0F \; fully-qualified # 🙍🏿‍♀️ E4.0 wo - man frowning: dark skin tone\n1F64D 1F3FF 200D 2640 \ - ; minimally-qualified # 🙍🏿‍♀ E4.0 woman frowning: dark skin tone - \n1F64E \; fully-qualified # 🙎 - E2.0 person pouting\n1F64E 1F3FB \; fully- - qualified # 🙎🏻 E2.0 person pouting: light skin tone\n1F64E 1F3FC - \; fully-qualified # 🙎🏼 E2.0 per - son pouting: medium-light skin tone\n1F64E 1F3FD - \; fully-qualified # 🙎🏽 E2.0 person pouting: medium skin t - one\n1F64E 1F3FE \; fully-qualified # - 🙎🏾 E2.0 person pouting: medium-dark skin tone\n1F64E 1F3FF - \; fully-qualified # 🙎🏿 E2.0 person poutin - g: dark skin tone\n1F64E 200D 2642 FE0F \; fully-qua - lified # 🙎‍♂️ E4.0 man pouting\n1F64E 200D 2642 - \; minimally-qualified # 🙎‍♂ E4.0 man pouting\n1F64E - 1F3FB 200D 2642 FE0F \; fully-qualified # 🙎🏻‍ - ♂️ E4.0 man pouting: light skin tone\n1F64E 1F3FB 200D 2642 - \; minimally-qualified # 🙎🏻‍♂ E4.0 man pouting: light - skin tone\n1F64E 1F3FC 200D 2642 FE0F \; fully-qualified - # 🙎🏼‍♂️ E4.0 man pouting: medium-light skin tone\n1F64E 1F - 3FC 200D 2642 \; minimally-qualified # 🙎🏼‍♂ - E4.0 man pouting: medium-light skin tone\n1F64E 1F3FD 200D 2642 FE0F - \; fully-qualified # 🙎��‍♂️ E4.0 man pouting: - medium skin tone\n1F64E 1F3FD 200D 2642 \; minimally - -qualified # 🙎🏽‍♂ E4.0 man pouting: medium skin tone\n1F64E 1F3F - E 200D 2642 FE0F \; fully-qualified # 🙎🏾‍♂ - ️ E4.0 man pouting: medium-dark skin tone\n1F64E 1F3FE 200D 2642 - \; minimally-qualified # 🙎🏾‍♂ E4.0 man pouting: me - dium-dark skin tone\n1F64E 1F3FF 200D 2642 FE0F \; fully-q - ualified # 🙎🏿‍♂️ E4.0 man pouting: dark skin tone\n1F64E 1 - F3FF 200D 2642 \; minimally-qualified # 🙎🏿‍ - ♂ E4.0 man pouting: dark skin tone\n1F64E 200D 2640 FE0F - \; fully-qualified # 🙎‍♀️ E4.0 woman pouting\n1F64E 20 - 0D 2640 \; minimally-qualified # 🙎‍♀ E4. - 0 woman pouting\n1F64E 1F3FB 200D 2640 FE0F \; fully-quali - fied # 🙎🏻‍♀️ E4.0 woman pouting: light skin tone\n1F64E 1F - 3FB 200D 2640 \; minimally-qualified # 🙎��‍ - ♀ E4.0 woman pouting: light skin tone\n1F64E 1F3FC 200D 2640 FE0F - \; fully-qualified # 🙎🏼‍♀️ E4.0 woman pouting: m - edium-light skin tone\n1F64E 1F3FC 200D 2640 \; minim - ally-qualified # 🙎🏼‍♀ E4.0 woman pouting: medium-light skin tone - \n1F64E 1F3FD 200D 2640 FE0F \; fully-qualified # 🙎 - 🏽‍♀️ E4.0 woman pouting: medium skin tone\n1F64E 1F3FD 200D 2640 - \; minimally-qualified # 🙎🏽‍♀ E4.0 woman po - uting: medium skin tone\n1F64E 1F3FE 200D 2640 FE0F \; ful - ly-qualified # 🙎🏾‍♀️ E4.0 woman pouting: medium-dark skin - tone\n1F64E 1F3FE 200D 2640 \; minimally-qualified # - 🙎🏾‍♀ E4.0 woman pouting: medium-dark skin tone\n1F64E 1F3FF 200D - 2640 FE0F \; fully-qualified # 🙎🏿‍♀️ E4.0 - woman pouting: dark skin tone\n1F64E 1F3FF 200D 2640 - \; minimally-qualified # 🙎🏿‍♀ E4.0 woman pouting: dark skin ton - e\n1F645 \; fully-qualified # - 🙅 E2.0 person gesturing NO\n1F645 1F3FB - \; fully-qualified # 🙅🏻 E2.0 person gesturing NO: light skin ton - e\n1F645 1F3FC \; fully-qualified # - 🙅🏼 E2.0 person gesturing NO: medium-light skin tone\n1F645 1F3FD - \; fully-qualified # 🙅🏽 E2.0 person - gesturing NO: medium skin tone\n1F645 1F3FE - \; fully-qualified # 🙅🏾 E2.0 person gesturing NO: medium-dark s - kin tone\n1F645 1F3FF \; fully-qualified - # 🙅🏿 E2.0 person gesturing NO: dark skin tone\n1F645 200D 2642 FE0 - F \; fully-qualified # 🙅‍♂️ E4.0 man ge - sturing NO\n1F645 200D 2642 \; minimally-qualif - ied # 🙅‍♂ E4.0 man gesturing NO\n1F645 1F3FB 200D 2642 FE0F - \; fully-qualified # 🙅🏻‍♂️ E4.0 man gesturing NO: - light skin tone\n1F645 1F3FB 200D 2642 \; minimally- - qualified # 🙅🏻‍♂ E4.0 man gesturing NO: light skin tone\n1F645 1 - F3FC 200D 2642 FE0F \; fully-qualified # 🙅🏼‍ - ♂️ E4.0 man gesturing NO: medium-light skin tone\n1F645 1F3FC 200D 264 - 2 \; minimally-qualified # 🙅🏼‍♂ E4.0 man ge - sturing NO: medium-light skin tone\n1F645 1F3FD 200D 2642 FE0F - \; fully-qualified # 🙅🏽‍♂️ E4.0 man gesturing NO: med - ium skin tone\n1F645 1F3FD 200D 2642 \; minimally-qua - lified # 🙅🏽‍♂ E4.0 man gesturing NO: medium skin tone\n1F645 1F3 - FE 200D 2642 FE0F \; fully-qualified # 🙅🏾‍♂ - ️ E4.0 man gesturing NO: medium-dark skin tone\n1F645 1F3FE 200D 2642 - \; minimally-qualified # 🙅🏾‍♂ E4.0 man gestur - ing NO: medium-dark skin tone\n1F645 1F3FF 200D 2642 FE0F - \; fully-qualified # 🙅🏿‍♂️ E4.0 man gesturing NO: dark ski - n tone\n1F645 1F3FF 200D 2642 \; minimally-qualified - # 🙅🏿‍♂ E4.0 man gesturing NO: dark skin tone\n1F645 200D 2640 FE - 0F \; fully-qualified # 🙅‍♀️ E4.0 woman - gesturing NO\n1F645 200D 2640 \; minimally-qua - lified # 🙅‍♀ E4.0 woman gesturing NO\n1F645 1F3FB 200D 2640 FE0F - \; fully-qualified # 🙅🏻‍♀️ E4.0 woman gestur - ing NO: light skin tone\n1F645 1F3FB 200D 2640 \; min - imally-qualified # 🙅🏻‍♀ E4.0 woman gesturing NO: light skin tone - \n1F645 1F3FC 200D 2640 FE0F \; fully-qualified # 🙅 - 🏼‍♀️ E4.0 woman gesturing NO: medium-light skin tone\n1F645 1F3FC - 200D 2640 \; minimally-qualified # 🙅🏼‍♀ E4 - .0 woman gesturing NO: medium-light skin tone\n1F645 1F3FD 200D 2640 FE0F - \; fully-qualified # 🙅🏽‍♀️ E4.0 woman gest - uring NO: medium skin tone\n1F645 1F3FD 200D 2640 \; - minimally-qualified # ��🏽‍♀ E4.0 woman gesturing NO: medium ski - n tone\n1F645 1F3FE 200D 2640 FE0F \; fully-qualified - # 🙅🏾‍♀️ E4.0 woman gesturing NO: medium-dark skin tone\n1F645 - 1F3FE 200D 2640 \; minimally-qualified # 🙅🏾‍ - ♀ E4.0 woman gesturing NO: medium-dark skin tone\n1F645 1F3FF 200D 2640 - FE0F \; fully-qualified # 🙅🏿‍♀️ E4.0 woman - gesturing NO: dark skin tone\n1F645 1F3FF 200D 2640 - \; minimally-qualified # 🙅🏿‍♀ E4.0 woman gesturing NO: dark skin - tone\n1F646 \; fully-qualified # - 🙆 E2.0 person gesturing OK\n1F646 1F3FB - \; fully-qualified # 🙆🏻 E2.0 person gesturing OK: light skin to - ne\n1F646 1F3FC \; fully-qualified # - 🙆🏼 E2.0 person gesturing OK: medium-light skin tone\n1F646 1F3FD - \; fully-qualified # 🙆🏽 E2.0 person - gesturing OK: medium skin tone\n1F646 1F3FE - \; fully-qualified # 🙆🏾 E2.0 person gesturing OK: medium-dark s - kin tone\n1F646 1F3FF \; fully-qualified - # 🙆🏿 E2.0 person gesturing OK: dark skin tone\n1F646 200D 2642 FE0 - F \; fully-qualified # 🙆‍♂️ E4.0 man ge - sturing OK\n1F646 200D 2642 \; minimally-qualif - ied # 🙆‍♂ E4.0 man gesturing OK\n1F646 1F3FB 200D 2642 FE0F - \; fully-qualified # 🙆🏻‍♂️ E4.0 man gesturing OK: - light skin tone\n1F646 1F3FB 200D 2642 \; minimally- - qualified # 🙆🏻‍♂ E4.0 man gesturing OK: light skin tone\n1F646 1 - F3FC 200D 2642 FE0F \; fully-qualified # 🙆🏼‍ - ♂️ E4.0 man gesturing OK: medium-light skin tone\n1F646 1F3FC 200D 264 - 2 \; minimally-qualified # 🙆🏼‍♂ E4.0 man ge - sturing OK: medium-light skin tone\n1F646 1F3FD 200D 2642 FE0F - \; fully-qualified # 🙆🏽‍♂️ E4.0 man gesturing OK: med - ium skin tone\n1F646 1F3FD 200D 2642 \; minimally-qua - lified # 🙆🏽‍♂ E4.0 man gesturing OK: medium skin tone\n1F646 1F3 - FE 200D 2642 FE0F \; fully-qualified # 🙆🏾‍♂ - ️ E4.0 man gesturing OK: medium-dark skin tone\n1F646 1F3FE 200D 2642 - \; minimally-qualified # 🙆🏾‍♂ E4.0 man gestur - ing OK: medium-dark skin tone\n1F646 1F3FF 200D 2642 FE0F - \; fully-qualified # 🙆🏿‍♂️ E4.0 man gesturing OK: dark ski - n tone\n1F646 1F3FF 200D 2642 \; minimally-qualified - # 🙆��‍♂ E4.0 man gesturing OK: dark skin tone\n1F646 200D 2640 - FE0F \; fully-qualified # 🙆‍♀️ E4.0 wom - an gesturing OK\n1F646 200D 2640 \; minimally-q - ualified # 🙆‍♀ E4.0 woman gesturing OK\n1F646 1F3FB 200D 2640 FE0F - \; fully-qualified # 🙆🏻‍♀️ E4.0 woman gest - uring OK: light skin tone\n1F646 1F3FB 200D 2640 \; m - inimally-qualified # 🙆🏻‍♀ E4.0 woman gesturing OK: light skin to - ne\n1F646 1F3FC 200D 2640 FE0F \; fully-qualified # - 🙆🏼‍♀️ E4.0 woman gesturing OK: medium-light skin tone\n1F646 1 - F3FC 200D 2640 \; minimally-qualified # 🙆🏼‍ - ♀ E4.0 woman gesturing OK: medium-light skin tone\n1F646 1F3FD 200D 2640 - FE0F \; fully-qualified # 🙆🏽‍♀️ E4.0 woma - n gesturing OK: medium skin tone\n1F646 1F3FD 200D 2640 - \; minimally-qualified # 🙆🏽‍♀ E4.0 woman gesturing OK: medium - skin tone\n1F646 1F3FE 200D 2640 FE0F \; fully-qualified - # 🙆🏾‍♀️ E4.0 woman gesturing OK: medium-dark skin tone\n1F - 646 1F3FE 200D 2640 \; minimally-qualified # 🙆🏾 - ‍♀ E4.0 woman gesturing OK: medium-dark skin tone\n1F646 1F3FF 200D 26 - 40 FE0F \; fully-qualified # 🙆🏿‍♀️ E4.0 wo - man gesturing OK: dark skin tone\n1F646 1F3FF 200D 2640 - \; minimally-qualified # 🙆🏿‍♀ E4.0 woman gesturing OK: dark s - kin tone\n1F481 \; fully-qualified - # 💁 E2.0 person tipping hand\n1F481 1F3FB - \; fully-qualified # 💁🏻 E2.0 person tipping hand: light skin - tone\n1F481 1F3FC \; fully-qualified # - 💁�� E2.0 person tipping hand: medium-light skin tone\n1F481 1F3FD - \; fully-qualified # 💁🏽 E2.0 pers - on tipping hand: medium skin tone\n1F481 1F3FE - \; fully-qualified # 💁🏾 E2.0 person tipping hand: medium-dar - k skin tone\n1F481 1F3FF \; fully-qualified - # 💁🏿 E2.0 person tipping hand: dark skin tone\n1F481 200D 2642 - FE0F \; fully-qualified # 💁‍♂️ E4.0 man - tipping hand\n1F481 200D 2642 \; minimally-qua - lified # 💁‍♂ E4.0 man tipping hand\n1F481 1F3FB 200D 2642 FE0F - \; fully-qualified # 💁🏻‍♂️ E4.0 man tipping ha - nd: light skin tone\n1F481 1F3FB 200D 2642 \; minimal - ly-qualified # 💁🏻‍♂ E4.0 man tipping hand: light skin tone\n1F48 - 1 1F3FC 200D 2642 FE0F \; fully-qualified # 💁🏼 - ‍♂️ E4.0 man tipping hand: medium-light skin tone\n1F481 1F3FC 200D - 2642 \; minimally-qualified # 💁🏼‍♂ E4.0 man - tipping hand: medium-light skin tone\n1F481 1F3FD 200D 2642 FE0F - \; fully-qualified # 💁🏽‍♂️ E4.0 man tipping hand: - medium skin tone\n1F481 1F3FD 200D 2642 \; minimally- - qualified # 💁🏽‍♂ E4.0 man tipping hand: medium skin tone\n1F481 - 1F3FE 200D 2642 FE0F \; fully-qualified # 💁🏾‍ - ♂️ E4.0 man tipping hand: medium-dark skin tone\n1F481 1F3FE 200D 2642 - \; minimally-qualified # 💁🏾‍♂ E4.0 man tip - ping hand: medium-dark skin tone\n1F481 1F3FF 200D 2642 FE0F - \; fully-qualified # 💁🏿‍♂️ E4.0 man tipping hand: dark - skin tone\n1F481 1F3FF 200D 2642 \; minimally-qualifi - ed # 💁🏿‍♂ E4.0 man tipping hand: dark skin tone\n1F481 200D 2640 - FE0F \; fully-qualified # 💁‍♀️ E4.0 wo - man tipping hand\n1F481 200D 2640 \; minimally- - qualified # 💁‍♀ E4.0 woman tipping hand\n1F481 1F3FB 200D 2640 FE0F - \; fully-qualified # 💁🏻‍♀️ E4.0 woman tip - ping hand: light skin tone\n1F481 1F3FB 200D 2640 \; - minimally-qualified # 💁🏻‍♀ E4.0 woman tipping hand: light skin t - one\n1F481 1F3FC 200D 2640 FE0F \; fully-qualified # - 💁🏼‍♀️ E4.0 woman tipping hand: medium-light skin tone\n1F481 1 - F3FC 200D 2640 \; minimally-qualified # 💁🏼‍ - ♀ E4.0 woman tipping hand: medium-light skin tone\n1F481 1F3FD 200D 2640 - FE0F \; fully-qualified # 💁🏽‍♀️ E4.0 woma - n tipping hand: medium skin tone\n1F481 1F3FD 200D 2640 - \; minimally-qualified # 💁🏽‍♀ E4.0 woman tipping hand: medium - skin tone\n1F481 1F3FE 200D 2640 FE0F \; fully-qualified - # 💁🏾‍♀️ E4.0 woman tipping hand: medium-dark skin tone\n1F - 481 1F3FE 200D 2640 \; minimally-qualified # 💁🏾 - ‍♀ E4.0 woman tipping hand: medium-dark skin tone\n1F481 1F3FF 200D 26 - 40 FE0F \; fully-qualified # 💁🏿‍♀️ E4.0 wo - man tipping hand: dark skin tone\n1F481 1F3FF 200D 2640 - \; minimally-qualified # 💁🏿‍♀ E4.0 woman tipping hand: dark s - kin tone\n1F64B \; fully-qualified - # 🙋 E2.0 person raising hand\n1F64B 1F3FB - \; fully-qualified # 🙋🏻 E2.0 person raising hand: light skin - tone\n1F64B 1F3FC \; fully-qualified # - 🙋🏼 E2.0 person raising hand: medium-light skin tone\n1F64B 1F3FD - \; fully-qualified # 🙋🏽 E2.0 person - raising hand: medium skin tone\n1F64B 1F3FE - \; fully-qualified # 🙋🏾 E2.0 person raising hand: medium-dark - skin tone\n1F64B 1F3FF \; fully-qualified - # 🙋🏿 E2.0 person raising hand: dark skin tone\n1F64B 200D 2642 FE - 0F \; fully-qualified # 🙋‍♂️ E4.0 man r - aising hand\n1F64B 200D 2642 \; minimally-quali - fied # 🙋‍♂ E4.0 man raising hand\n1F64B 1F3FB 200D 2642 FE0F - \; fully-qualified # 🙋🏻‍♂️ E4.0 man raising hand - : light skin tone\n1F64B 1F3FB 200D 2642 \; minimally - -qualified # 🙋🏻‍♂ E4.0 man raising hand: light skin tone\n1F64B - 1F3FC 200D 2642 FE0F \; fully-qualified # 🙋🏼‍ - ♂️ E4.0 man raising hand: medium-light skin tone\n1F64B 1F3FC 200D 264 - 2 \; minimally-qualified # 🙋🏼‍♂ E4.0 man ra - ising hand: medium-light skin tone\n1F64B 1F3FD 200D 2642 FE0F - \; fully-qualified # 🙋🏽‍♂️ E4.0 man raising hand: med - ium skin tone\n1F64B 1F3FD 200D 2642 \; minimally-qua - lified # 🙋��‍♂ E4.0 man raising hand: medium skin tone\n1F64B 1 - F3FE 200D 2642 FE0F \; fully-qualified # 🙋🏾‍ - ♂️ E4.0 man raising hand: medium-dark skin tone\n1F64B 1F3FE 200D 2642 - \; minimally-qualified # 🙋🏾‍♂ E4.0 man rai - sing hand: medium-dark skin tone\n1F64B 1F3FF 200D 2642 FE0F - \; fully-qualified # 🙋🏿‍♂️ E4.0 man raising hand: dark - skin tone\n1F64B 1F3FF 200D 2642 \; minimally-qualifi - ed # 🙋🏿‍♂ E4.0 man raising hand: dark skin tone\n1F64B 200D 2640 - FE0F \; fully-qualified # 🙋‍♀️ E4.0 wo - man raising hand\n1F64B 200D 2640 \; minimally- - qualified # 🙋‍♀ E4.0 woman raising hand\n1F64B 1F3FB 200D 2640 FE0F - \; fully-qualified # 🙋🏻‍♀️ E4.0 woman rai - sing hand: light skin tone\n1F64B 1F3FB 200D 2640 \; - minimally-qualified # 🙋🏻‍♀ E4.0 woman raising hand: light skin t - one\n1F64B 1F3FC 200D 2640 FE0F \; fully-qualified # - 🙋🏼‍♀️ E4.0 woman raising hand: medium-light skin tone\n1F64B 1 - F3FC 200D 2640 \; minimally-qualified # 🙋🏼‍ - ♀ E4.0 woman raising hand: medium-light skin tone\n1F64B 1F3FD 200D 2640 - FE0F \; fully-qualified # 🙋🏽‍♀️ E4.0 woma - n raising hand: medium skin tone\n1F64B 1F3FD 200D 2640 - \; minimally-qualified # 🙋🏽‍♀ E4.0 woman raising hand: medium - skin tone\n1F64B 1F3FE 200D 2640 FE0F \; fully-qualified - # 🙋🏾‍♀️ E4.0 woman raising hand: medium-dark skin tone\n1F - 64B 1F3FE 200D 2640 \; minimally-qualified # 🙋🏾 - ‍♀ E4.0 woman raising hand: medium-dark skin tone\n1F64B 1F3FF 200D 26 - 40 FE0F \; fully-qualified # 🙋🏿‍♀️ E4.0 wo - man raising hand: dark skin tone\n1F64B 1F3FF 200D 2640 - \; minimally-qualified # 🙋🏿‍♀ E4.0 woman raising hand: dark s - kin tone\n1F9CF \; fully-qualified - # 🧏 E12.1 deaf person\n1F9CF 1F3FB \; - fully-qualified # 🧏🏻 E12.1 deaf person: light skin tone\n1F9CF 1 - F3FC \; fully-qualified # 🧏🏼 E12. - 1 deaf person: medium-light skin tone\n1F9CF 1F3FD - \; fully-qualified # 🧏🏽 E12.1 deaf person: medium skin t - one\n1F9CF 1F3FE \; fully-qualified # - 🧏🏾 E12.1 deaf person: medium-dark skin tone\n1F9CF 1F3FF - \; fully-qualified # 🧏🏿 E12.1 deaf person: d - ark skin tone\n1F9CF 200D 2642 FE0F \; fully-qualifi - ed # 🧏‍♂️ E12.1 deaf man\n1F9CF 200D 2642 - \; minimally-qualified # 🧏‍♂ E12.1 deaf man\n1F9CF 1F3FB 20 - 0D 2642 FE0F \; fully-qualified # 🧏🏻‍♂️ E1 - 2.1 deaf man: light skin tone\n1F9CF 1F3FB 200D 2642 - \; minimally-qualified # 🧏🏻‍♂ E12.1 deaf man: light skin tone\n1 - F9CF 1F3FC 200D 2642 FE0F \; fully-qualified # 🧏 - 🏼‍♂️ E12.1 deaf man: medium-light skin tone\n1F9CF 1F3FC 200D 264 - 2 \; minimally-qualified # 🧏🏼‍♂ E12.1 deaf - man: medium-light skin tone\n1F9CF 1F3FD 200D 2642 FE0F \; - fully-qualified # 🧏🏽‍♂️ E12.1 deaf man: medium skin tone\ - n1F9CF 1F3FD 200D 2642 \; minimally-qualified # 🧏 - 🏽‍♂ E12.1 deaf man: medium skin tone\n1F9CF 1F3FE 200D 2642 FE0F - \; fully-qualified # 🧏🏾‍♂️ E12.1 deaf man: m - edium-dark skin tone\n1F9CF 1F3FE 200D 2642 \; minima - lly-qualified # 🧏🏾‍♂ E12.1 deaf man: medium-dark skin tone\n1F9C - F 1F3FF 200D 2642 FE0F \; fully-qualified # 🧏🏿 - ‍♂️ E12.1 deaf man: dark skin tone\n1F9CF 1F3FF 200D 2642 - \; minimally-qualified # 🧏🏿‍♂ E12.1 deaf man: dark sk - in tone\n1F9CF 200D 2640 FE0F \; fully-qualified - # 🧏‍♀️ E12.1 deaf woman\n1F9CF 200D 2640 - \; minimally-qualified # 🧏‍♀ E12.1 deaf woman\n1F9CF 1F3FB 200D - 2640 FE0F \; fully-qualified # 🧏🏻‍♀️ E12. - 1 deaf woman: light skin tone\n1F9CF 1F3FB 200D 2640 - \; minimally-qualified # 🧏🏻‍♀ E12.1 deaf woman: light skin tone\ - n1F9CF 1F3FC 200D 2640 FE0F \; fully-qualified # 🧏 - 🏼‍♀️ E12.1 deaf woman: medium-light skin tone\n1F9CF 1F3FC 200D 2 - 640 \; minimally-qualified # 🧏🏼‍♀ E12.1 dea - f woman: medium-light skin tone\n1F9CF 1F3FD 200D 2640 FE0F - \; fully-qualified # 🧏🏽‍♀️ E12.1 deaf woman: medium skin - tone\n1F9CF 1F3FD 200D 2640 \; minimally-qualified # - 🧏🏽‍♀ E12.1 deaf woman: medium skin tone\n1F9CF 1F3FE 200D 2640 - FE0F \; fully-qualified # 🧏🏾‍♀️ E12.1 deaf - woman: medium-dark skin tone\n1F9CF 1F3FE 200D 2640 - \; minimally-qualified # 🧏🏾‍♀ E12.1 deaf woman: medium-dark skin - tone\n1F9CF 1F3FF 200D 2640 FE0F \; fully-qualified # - 🧏🏿‍♀️ E12.1 deaf woman: dark skin tone\n1F9CF 1F3FF 200D 2640 - \; minimally-qualified # 🧏🏿‍♀ E12.1 deaf w - oman: dark skin tone\n1F647 \; fully- - qualified # 🙇 E2.0 person bowing\n1F647 1F3FB - \; fully-qualified # 🙇🏻 E2.0 person bowing: light skin - tone\n1F647 1F3FC \; fully-qualified # - 🙇🏼 E2.0 person bowing: medium-light skin tone\n1F647 1F3FD - \; fully-qualified # 🙇🏽 E2.0 person bowin - g: medium skin tone\n1F647 1F3FE \; fully-q - ualified # 🙇🏾 E2.0 person bowing: medium-dark skin tone\n1F647 1 - F3FF \; fully-qualified # 🙇🏿 E2.0 - person bowing: dark skin tone\n1F647 200D 2642 FE0F - \; fully-qualified # 🙇‍♂️ E4.0 man bowing\n1F647 200D 2642 - \; minimally-qualified # 🙇‍♂ E4.0 man bow - ing\n1F647 1F3FB 200D 2642 FE0F \; fully-qualified # - 🙇🏻‍♂️ E4.0 man bowing: light skin tone\n1F647 1F3FB 200D 2642 - \; minimally-qualified # 🙇🏻‍♂ E4.0 man bowi - ng: light skin tone\n1F647 1F3FC 200D 2642 FE0F \; fully-q - ualified # 🙇🏼‍♂️ E4.0 man bowing: medium-light skin tone\n - 1F647 1F3FC 200D 2642 \; minimally-qualified # 🙇 - 🏼‍♂ E4.0 man bowing: medium-light skin tone\n1F647 1F3FD 200D 2642 - FE0F \; fully-qualified # 🙇��‍♂️ E4.0 man - bowing: medium skin tone\n1F647 1F3FD 200D 2642 \; m - inimally-qualified # 🙇🏽‍♂ E4.0 man bowing: medium skin tone\n1F6 - 47 1F3FE 200D 2642 FE0F \; fully-qualified # 🙇🏾 - ‍♂️ E4.0 man bowing: medium-dark skin tone\n1F647 1F3FE 200D 2642 - \; minimally-qualified # 🙇🏾‍♂ E4.0 man bowing - : medium-dark skin tone\n1F647 1F3FF 200D 2642 FE0F \; ful - ly-qualified # ��🏿‍♂️ E4.0 man bowing: dark skin tone\n1F - 647 1F3FF 200D 2642 \; minimally-qualified # 🙇🏿 - ‍♂ E4.0 man bowing: dark skin tone\n1F647 200D 2640 FE0F - \; fully-qualified # 🙇‍♀️ E4.0 woman bowing\n1F647 2 - 00D 2640 \; minimally-qualified # 🙇‍♀ E4 - .0 woman bowing\n1F647 1F3FB 200D 2640 FE0F \; fully-quali - fied # 🙇🏻‍♀️ E4.0 woman bowing: light skin tone\n1F647 1F3 - FB 200D 2640 \; minimally-qualified # 🙇🏻‍♀ - E4.0 woman bowing: light skin tone\n1F647 1F3FC 200D 2640 FE0F - \; fully-qualified # 🙇🏼‍♀️ E4.0 woman bowing: medium- - light skin tone\n1F647 1F3FC 200D 2640 \; minimally-q - ualified # 🙇🏼‍♀ E4.0 woman bowing: medium-light skin tone\n1F647 - 1F3FD 200D 2640 FE0F \; fully-qualified # 🙇🏽‍ - ♀️ E4.0 woman bowing: medium skin tone\n1F647 1F3FD 200D 2640 - \; minimally-qualified # ��🏽‍♀ E4.0 woman bowing: - medium skin tone\n1F647 1F3FE 200D 2640 FE0F \; fully-qual - ified # 🙇🏾‍♀️ E4.0 woman bowing: medium-dark skin tone\n1F - 647 1F3FE 200D 2640 \; minimally-qualified # 🙇🏾 - ‍♀ E4.0 woman bowing: medium-dark skin tone\n1F647 1F3FF 200D 2640 FE0 - F \; fully-qualified # 🙇🏿‍♀️ E4.0 woman bo - wing: dark skin tone\n1F647 1F3FF 200D 2640 \; minima - lly-qualified # 🙇🏿‍♀ E4.0 woman bowing: dark skin tone\n1F926 - \; fully-qualified # 🤦 E4.0 pers - on facepalming\n1F926 1F3FB \; fully-qualif - ied # 🤦🏻 E4.0 person facepalming: light skin tone\n1F926 1F3FC - \; fully-qualified # 🤦🏼 E4.0 perso - n facepalming: medium-light skin tone\n1F926 1F3FD - \; fully-qualified # 🤦🏽 E4.0 person facepalming: medium - skin tone\n1F926 1F3FE \; fully-qualified - # 🤦🏾 E4.0 person facepalming: medium-dark skin tone\n1F926 1F3FF - \; fully-qualified # 🤦🏿 E4.0 pers - on facepalming: dark skin tone\n1F926 200D 2642 FE0F - \; fully-qualified # 🤦‍♂️ E4.0 man facepalming\n1F926 200D 2 - 642 \; minimally-qualified # 🤦‍♂ E4.0 ma - n facepalming\n1F926 1F3FB 200D 2642 FE0F \; fully-qualifi - ed # 🤦🏻‍♂️ E4.0 man facepalming: light skin tone\n1F926 1F - 3FB 200D 2642 \; minimally-qualified # 🤦🏻‍♂ - E4.0 man facepalming: light skin tone\n1F926 1F3FC 200D 2642 FE0F - \; fully-qualified # 🤦🏼‍♂️ E4.0 man facepalming: - medium-light skin tone\n1F926 1F3FC 200D 2642 \; mini - mally-qualified # 🤦🏼‍♂ E4.0 man facepalming: medium-light skin t - one\n1F926 1F3FD 200D 2642 FE0F \; fully-qualified # - 🤦🏽‍♂️ E4.0 man facepalming: medium skin tone\n1F926 1F3FD 200D - 2642 \; minimally-qualified # 🤦🏽‍♂ E4.0 ma - n facepalming: medium skin tone\n1F926 1F3FE 200D 2642 FE0F - \; fully-qualified # 🤦🏾‍♂️ E4.0 man facepalming: medium- - dark skin tone\n1F926 1F3FE 200D 2642 \; minimally-qu - alified # 🤦🏾‍♂ E4.0 man facepalming: medium-dark skin tone\n1F92 - 6 1F3FF 200D 2642 FE0F \; fully-qualified # 🤦🏿 - ‍♂️ E4.0 man facepalming: dark skin tone\n1F926 1F3FF 200D 2642 - \; minimally-qualified # 🤦🏿‍♂ E4.0 man facepalm - ing: dark skin tone\n1F926 200D 2640 FE0F \; fully-q - ualified # 🤦‍♀️ E4.0 woman facepalming\n1F926 200D 2640 - \; minimally-qualified # 🤦‍♀ E4.0 woman facep - alming\n1F926 1F3FB 200D 2640 FE0F \; fully-qualified - # 🤦🏻‍♀️ E4.0 woman facepalming: light skin tone\n1F926 1F3FB 2 - 00D 2640 \; minimally-qualified # 🤦🏻‍♀ E4.0 - woman facepalming: light skin tone\n1F926 1F3FC 200D 2640 FE0F - \; fully-qualified # 🤦🏼‍♀️ E4.0 woman facepalming: m - edium-light skin tone\n1F926 1F3FC 200D 2640 \; minim - ally-qualified # 🤦🏼‍♀ E4.0 woman facepalming: medium-light skin - tone\n1F926 1F3FD 200D 2640 FE0F \; fully-qualified # - 🤦🏽‍♀️ E4.0 woman facepalming: medium skin tone\n1F926 1F3FD 20 - 0D 2640 \; minimally-qualified # 🤦🏽‍♀ E4.0 - woman facepalming: medium skin tone\n1F926 1F3FE 200D 2640 FE0F - \; fully-qualified # 🤦🏾‍♀️ E4.0 woman facepalming: m - edium-dark skin tone\n1F926 1F3FE 200D 2640 \; minima - lly-qualified # 🤦🏾‍♀ E4.0 woman facepalming: medium-dark skin to - ne\n1F926 1F3FF 200D 2640 FE0F \; fully-qualified # - 🤦🏿‍♀️ E4.0 woman facepalming: dark skin tone\n1F926 1F3FF 200D - 2640 \; minimally-qualified # 🤦🏿‍♀ E4.0 wo - man facepalming: dark skin tone\n1F937 - \; fully-qualified # 🤷 E4.0 person shrugging\n1F937 1F3FB - \; fully-qualified # 🤷🏻 E4.0 person shru - gging: light skin tone\n1F937 1F3FC \; full - y-qualified # 🤷🏼 E4.0 person shrugging: medium-light skin tone\n - 1F937 1F3FD \; fully-qualified # 🤷 - 🏽 E4.0 person shrugging: medium skin tone\n1F937 1F3FE - \; fully-qualified # 🤷🏾 E4.0 person shrugging: me - dium-dark skin tone\n1F937 1F3FF \; fully-q - ualified # 🤷🏿 E4.0 person shrugging: dark skin tone\n1F937 200D - 2642 FE0F \; fully-qualified # 🤷‍♂️ E4. - 0 man shrugging\n1F937 200D 2642 \; minimally-q - ualified # 🤷‍♂ E4.0 man shrugging\n1F937 1F3FB 200D 2642 FE0F - \; fully-qualified # 🤷🏻‍♂️ E4.0 man shrugging: - light skin tone\n1F937 1F3FB 200D 2642 \; minimally-q - ualified # 🤷🏻‍♂ E4.0 man shrugging: light skin tone\n1F937 1F3FC - 200D 2642 FE0F \; fully-qualified # 🤷🏼‍♂️ - E4.0 man shrugging: medium-light skin tone\n1F937 1F3FC 200D 2642 - \; minimally-qualified # 🤷🏼‍♂ E4.0 man shrugging: - medium-light skin tone\n1F937 1F3FD 200D 2642 FE0F \; full - y-qualified # 🤷��‍♂️ E4.0 man shrugging: medium skin tone - \n1F937 1F3FD 200D 2642 \; minimally-qualified # 🤷 - 🏽‍♂ E4.0 man shrugging: medium skin tone\n1F937 1F3FE 200D 2642 FE0 - F \; fully-qualified # ��🏾‍♂️ E4.0 man sh - rugging: medium-dark skin tone\n1F937 1F3FE 200D 2642 - \; minimally-qualified # 🤷🏾‍♂ E4.0 man shrugging: medium-dark s - kin tone\n1F937 1F3FF 200D 2642 FE0F \; fully-qualified - # 🤷🏿‍♂️ E4.0 man shrugging: dark skin tone\n1F937 1F3FF 200D - 2642 \; minimally-qualified # 🤷🏿‍♂ E4.0 ma - n shrugging: dark skin tone\n1F937 200D 2640 FE0F \; - fully-qualified # 🤷‍♀️ E4.0 woman shrugging\n1F937 200D 2640 - \; minimally-qualified # 🤷‍♀ E4.0 woman - shrugging\n1F937 1F3FB 200D 2640 FE0F \; fully-qualified - # 🤷🏻‍♀️ E4.0 woman shrugging: light skin tone\n1F937 1F3FB - 200D 2640 \; minimally-qualified # 🤷🏻‍♀ E4 - .0 woman shrugging: light skin tone\n1F937 1F3FC 200D 2640 FE0F - \; fully-qualified # 🤷🏼‍♀️ E4.0 woman shrugging: med - ium-light skin tone\n1F937 1F3FC 200D 2640 \; minimal - ly-qualified # 🤷🏼‍♀ E4.0 woman shrugging: medium-light skin tone - \n1F937 1F3FD 200D 2640 FE0F \; fully-qualified # 🤷 - ��‍♀️ E4.0 woman shrugging: medium skin tone\n1F937 1F3FD 200D 2 - 640 \; minimally-qualified # 🤷🏽‍♀ E4.0 woma - n shrugging: medium skin tone\n1F937 1F3FE 200D 2640 FE0F - \; fully-qualified # 🤷🏾‍♀️ E4.0 woman shrugging: medium-da - rk skin tone\n1F937 1F3FE 200D 2640 \; minimally-qual - ified # 🤷🏾‍♀ E4.0 woman shrugging: medium-dark skin tone\n1F937 - 1F3FF 200D 2640 FE0F \; fully-qualified # 🤷🏿‍ - ♀️ E4.0 woman shrugging: dark skin tone\n1F937 1F3FF 200D 2640 - \; minimally-qualified # 🤷🏿‍♀ E4.0 woman shrugging - : dark skin tone\n\n# subgroup: person-role\n1F9D1 200D 2695 FE0F - \; fully-qualified # 🧑‍⚕️ E12.1 health worker\n - 1F9D1 200D 2695 \; minimally-qualified # 🧑 - ‍⚕ E12.1 health worker\n1F9D1 1F3FB 200D 2695 FE0F \; - fully-qualified # 🧑🏻‍⚕️ E12.1 health worker: light skin to - ne\n1F9D1 1F3FB 200D 2695 \; minimally-qualified # - 🧑🏻‍⚕ E12.1 health worker: light skin tone\n1F9D1 1F3FC 200D 2695 - FE0F \; fully-qualified # 🧑🏼‍⚕️ E12.1 hea - lth worker: medium-light skin tone\n1F9D1 1F3FC 200D 2695 - \; minimally-qualified # 🧑🏼‍⚕ E12.1 health worker: medium-l - ight skin tone\n1F9D1 1F3FD 200D 2695 FE0F \; fully-qualif - ied # 🧑🏽‍⚕️ E12.1 health worker: medium skin tone\n1F9D1 1 - F3FD 200D 2695 \; minimally-qualified # 🧑🏽‍ - ⚕ E12.1 health worker: medium skin tone\n1F9D1 1F3FE 200D 2695 FE0F - \; fully-qualified # 🧑🏾‍⚕️ E12.1 health worker - : medium-dark skin tone\n1F9D1 1F3FE 200D 2695 \; min - imally-qualified # 🧑🏾‍⚕ E12.1 health worker: medium-dark skin to - ne\n1F9D1 1F3FF 200D 2695 FE0F \; fully-qualified # - 🧑🏿‍⚕️ E12.1 health worker: dark skin tone\n1F9D1 1F3FF 200D 26 - 95 \; minimally-qualified # 🧑🏿‍⚕ E12.1 heal - th worker: dark skin tone\n1F468 200D 2695 FE0F \; f - ully-qualified # 👨‍⚕️ E4.0 man health worker\n1F468 200D 2695 - \; minimally-qualified # 👨‍⚕ E4.0 man h - ealth worker\n1F468 1F3FB 200D 2695 FE0F \; fully-qualifie - d # 👨🏻‍⚕️ E4.0 man health worker: light skin tone\n1F468 1 - F3FB 200D 2695 \; minimally-qualified # 👨🏻‍ - ⚕ E4.0 man health worker: light skin tone\n1F468 1F3FC 200D 2695 FE0F - \; fully-qualified # 👨🏼‍⚕️ E4.0 man health w - orker: medium-light skin tone\n1F468 1F3FC 200D 2695 - \; minimally-qualified # 👨🏼‍⚕ E4.0 man health worker: medium-lig - ht skin tone\n1F468 1F3FD 200D 2695 FE0F \; fully-qualifie - d # 👨🏽‍⚕️ E4.0 man health worker: medium skin tone\n1F468 - 1F3FD 200D 2695 \; minimally-qualified # 👨🏽‍ - ⚕ E4.0 man health worker: medium skin tone\n1F468 1F3FE 200D 2695 FE0F - \; fully-qualified # 👨🏾‍⚕️ E4.0 man health - worker: medium-dark skin tone\n1F468 1F3FE 200D 2695 - \; minimally-qualified # 👨🏾‍⚕ E4.0 man health worker: medium-dar - k skin tone\n1F468 1F3FF 200D 2695 FE0F \; fully-qualified - # 👨🏿‍⚕️ E4.0 man health worker: dark skin tone\n1F468 1F3 - FF 200D 2695 \; minimally-qualified # 👨🏿‍⚕ - E4.0 man health worker: dark skin tone\n1F469 200D 2695 FE0F - \; fully-qualified # 👩‍⚕️ E4.0 woman health worker\n - 1F469 200D 2695 \; minimally-qualified # 👩 - ‍⚕ E4.0 woman health worker\n1F469 1F3FB 200D 2695 FE0F - \; fully-qualified # 👩🏻‍⚕️ E4.0 woman health worker: lig - ht skin tone\n1F469 1F3FB 200D 2695 \; minimally-qual - ified # 👩🏻‍⚕ E4.0 woman health worker: light skin tone\n1F469 1F - 3FC 200D 2695 FE0F \; fully-qualified # 👩🏼‍⚕ - ️ E4.0 woman health worker: medium-light skin tone\n1F469 1F3FC 200D 269 - 5 \; minimally-qualified # 👩🏼‍⚕ E4.0 woman - health worker: medium-light skin tone\n1F469 1F3FD 200D 2695 FE0F - \; fully-qualified # 👩🏽‍⚕️ E4.0 woman health worke - r: medium skin tone\n1F469 1F3FD 200D 2695 \; minimal - ly-qualified # 👩🏽‍⚕ E4.0 woman health worker: medium skin tone\n - 1F469 1F3FE 200D 2695 FE0F \; fully-qualified # 👩 - 🏾‍⚕️ E4.0 woman health worker: medium-dark skin tone\n1F469 1F3FE - 200D 2695 \; minimally-qualified # 👩🏾‍⚕ E4 - .0 woman health worker: medium-dark skin tone\n1F469 1F3FF 200D 2695 FE0F - \; fully-qualified # 👩🏿‍⚕️ E4.0 woman heal - th worker: dark skin tone\n1F469 1F3FF 200D 2695 \; m - inimally-qualified # 👩🏿‍⚕ E4.0 woman health worker: dark skin to - ne\n1F9D1 200D 1F393 \; fully-qualified # - 🧑‍🎓 E12.1 student\n1F9D1 1F3FB 200D 1F393 \; f - ully-qualified # 🧑🏻‍🎓 E12.1 student: light skin tone\n1F9D1 - 1F3FC 200D 1F393 \; fully-qualified # 🧑🏼‍ - 🎓 E12.1 student: medium-light skin tone\n1F9D1 1F3FD 200D 1F393 - \; fully-qualified # 🧑🏽‍🎓 E12.1 student: mediu - m skin tone\n1F9D1 1F3FE 200D 1F393 \; fully-qualified - # 🧑🏾‍🎓 E12.1 student: medium-dark skin tone\n1F9D1 1F3FF 2 - 00D 1F393 \; fully-qualified # 🧑🏿‍🎓 E12 - .1 student: dark skin tone\n1F468 200D 1F393 \; - fully-qualified # 👨‍🎓 E4.0 man student\n1F468 1F3FB 200D 1F393 - \; fully-qualified # 👨🏻‍🎓 E4.0 man stu - dent: light skin tone\n1F468 1F3FC 200D 1F393 \; fully - -qualified # 👨🏼‍🎓 E4.0 man student: medium-light skin tone\ - n1F468 1F3FD 200D 1F393 \; fully-qualified # 👨 - 🏽‍🎓 E4.0 man student: medium skin tone\n1F468 1F3FE 200D 1F393 - \; fully-qualified # 👨🏾‍🎓 E4.0 man student - : medium-dark skin tone\n1F468 1F3FF 200D 1F393 \; ful - ly-qualified # 👨🏿‍🎓 E4.0 man student: dark skin tone\n1F469 - 200D 1F393 \; fully-qualified # 👩‍🎓 - E4.0 woman student\n1F469 1F3FB 200D 1F393 \; fully-q - ualified # 👩🏻‍🎓 E4.0 woman student: light skin tone\n1F469 - 1F3FC 200D 1F393 \; fully-qualified # 👩🏼‍ - 🎓 E4.0 woman student: medium-light skin tone\n1F469 1F3FD 200D 1F393 - \; fully-qualified # 👩🏽‍🎓 E4.0 woman stud - ent: medium skin tone\n1F469 1F3FE 200D 1F393 \; fully - -qualified # 👩🏾‍🎓 E4.0 woman student: medium-dark skin tone - \n1F469 1F3FF 200D 1F393 \; fully-qualified # 👩 - 🏿‍🎓 E4.0 woman student: dark skin tone\n1F9D1 200D 1F3EB - \; fully-qualified # 🧑‍🏫 E12.1 teacher\n1F9D1 - 1F3FB 200D 1F3EB \; fully-qualified # 🧑🏻‍ - 🏫 E12.1 teacher: light skin tone\n1F9D1 1F3FC 200D 1F3EB - \; fully-qualified # 🧑🏼‍🏫 E12.1 teacher: medium-light - skin tone\n1F9D1 1F3FD 200D 1F3EB \; fully-qualified - # 🧑🏽‍🏫 E12.1 teacher: medium skin tone\n1F9D1 1F3FE 200D 1F - 3EB \; fully-qualified # 🧑🏾‍🏫 E12.1 tea - cher: medium-dark skin tone\n1F9D1 1F3FF 200D 1F3EB \; - fully-qualified # 🧑🏿‍🏫 E12.1 teacher: dark skin tone\n1F46 - 8 200D 1F3EB \; fully-qualified # 👨‍ - 🏫 E4.0 man teacher\n1F468 1F3FB 200D 1F3EB \; fully - -qualified # 👨🏻‍🏫 E4.0 man teacher: light skin tone\n1F468 - 1F3FC 200D 1F3EB \; fully-qualified # 👨🏼‍ - 🏫 E4.0 man teacher: medium-light skin tone\n1F468 1F3FD 200D 1F3EB - \; fully-qualified # 👨🏽‍🏫 E4.0 man teacher: - medium skin tone\n1F468 1F3FE 200D 1F3EB \; fully-qua - lified # 👨🏾‍🏫 E4.0 man teacher: medium-dark skin tone\n1F46 - 8 1F3FF 200D 1F3EB \; fully-qualified # 👨🏿 - ‍🏫 E4.0 man teacher: dark skin tone\n1F469 200D 1F3EB - \; fully-qualified # 👩‍🏫 E4.0 woman teacher\n1F469 - 1F3FB 200D 1F3EB \; fully-qualified # 👩🏻‍ - 🏫 E4.0 woman teacher: light skin tone\n1F469 1F3FC 200D 1F3EB - \; fully-qualified # 👩🏼‍🏫 E4.0 woman teacher: me - dium-light skin tone\n1F469 1F3FD 200D 1F3EB \; fully- - qualified # 👩��‍🏫 E4.0 woman teacher: medium skin tone\n1F - 469 1F3FE 200D 1F3EB \; fully-qualified # 👩🏾 - ‍🏫 E4.0 woman teacher: medium-dark skin tone\n1F469 1F3FF 200D 1F3EB - \; fully-qualified # 👩🏿‍🏫 E4.0 woman te - acher: dark skin tone\n1F9D1 200D 2696 FE0F \; fully - -qualified # 🧑‍⚖️ E12.1 judge\n1F9D1 200D 2696 - \; minimally-qualified # 🧑‍⚖ E12.1 judge\n1F9D1 1F3FB - 200D 2696 FE0F \; fully-qualified # 🧑🏻‍⚖️ - E12.1 judge: light skin tone\n1F9D1 1F3FB 200D 2696 \ - ; minimally-qualified # 🧑🏻‍⚖ E12.1 judge: light skin tone\n1F9D1 - 1F3FC 200D 2696 FE0F \; fully-qualified # 🧑🏼‍ - ⚖️ E12.1 judge: medium-light skin tone\n1F9D1 1F3FC 200D 2696 - \; minimally-qualified # 🧑🏼‍⚖ E12.1 judge: medium-l - ight skin tone\n1F9D1 1F3FD 200D 2696 FE0F \; fully-qualif - ied # 🧑🏽‍⚖️ E12.1 judge: medium skin tone\n1F9D1 1F3FD 200 - D 2696 \; minimally-qualified # 🧑🏽‍⚖ E12.1 - judge: medium skin tone\n1F9D1 1F3FE 200D 2696 FE0F \; ful - ly-qualified # 🧑🏾‍⚖️ E12.1 judge: medium-dark skin tone\n1 - F9D1 1F3FE 200D 2696 \; minimally-qualified # 🧑 - 🏾‍⚖ E12.1 judge: medium-dark skin tone\n1F9D1 1F3FF 200D 2696 FE0F - \; fully-qualified # 🧑🏿‍⚖️ E12.1 judge: da - rk skin tone\n1F9D1 1F3FF 200D 2696 \; minimally-qual - ified # 🧑🏿‍⚖ E12.1 judge: dark skin tone\n1F468 200D 2696 FE0F - \; fully-qualified # 👨‍⚖️ E4.0 man judge - \n1F468 200D 2696 \; minimally-qualified # 👨 - ‍⚖ E4.0 man judge\n1F468 1F3FB 200D 2696 FE0F \; fully - -qualified # 👨🏻‍⚖️ E4.0 man judge: light skin tone\n1F468 - 1F3FB 200D 2696 \; minimally-qualified # 👨🏻‍ - ⚖ E4.0 man judge: light skin tone\n1F468 1F3FC 200D 2696 FE0F - \; fully-qualified # 👨🏼‍⚖️ E4.0 man judge: medium-li - ght skin tone\n1F468 1F3FC 200D 2696 \; minimally-qua - lified # 👨🏼‍⚖ E4.0 man judge: medium-light skin tone\n1F468 1F3F - D 200D 2696 FE0F \; fully-qualified # 👨🏽‍⚖ - ️ E4.0 man judge: medium skin tone\n1F468 1F3FD 200D 2696 - \; minimally-qualified # 👨🏽‍⚖ E4.0 man judge: medium skin - tone\n1F468 1F3FE 200D 2696 FE0F \; fully-qualified # - 👨🏾‍⚖️ E4.0 man judge: medium-dark skin tone\n1F468 1F3FE 200D - 2696 \; minimally-qualified # 👨🏾‍⚖ E4.0 ma - n judge: medium-dark skin tone\n1F468 1F3FF 200D 2696 FE0F - \; fully-qualified # 👨🏿‍⚖️ E4.0 man judge: dark skin tone - \n1F468 1F3FF 200D 2696 \; minimally-qualified # 👨 - 🏿‍⚖ E4.0 man judge: dark skin tone\n1F469 200D 2696 FE0F - \; fully-qualified # 👩‍⚖️ E4.0 woman judge\n1F469 - 200D 2696 \; minimally-qualified # 👩‍⚖ - E4.0 woman judge\n1F469 1F3FB 200D 2696 FE0F \; fully-qual - ified # 👩🏻‍⚖️ E4.0 woman judge: light skin tone\n1F469 1F3 - FB 200D 2696 \; minimally-qualified # 👩🏻‍⚖ - E4.0 woman judge: light skin tone\n1F469 1F3FC 200D 2696 FE0F - \; fully-qualified # 👩🏼‍⚖️ E4.0 woman judge: medium-li - ght skin tone\n1F469 1F3FC 200D 2696 \; minimally-qua - lified # 👩🏼‍⚖ E4.0 woman judge: medium-light skin tone\n1F469 1F - 3FD 200D 2696 FE0F \; fully-qualified # 👩🏽‍⚖ - ️ E4.0 woman judge: medium skin tone\n1F469 1F3FD 200D 2696 - \; minimally-qualified # 👩🏽‍⚖ E4.0 woman judge: medium - skin tone\n1F469 1F3FE 200D 2696 FE0F \; fully-qualified - # 👩🏾‍⚖️ E4.0 woman judge: medium-dark skin tone\n1F469 1F3F - E 200D 2696 \; minimally-qualified # 👩🏾‍⚖ E - 4.0 woman judge: medium-dark skin tone\n1F469 1F3FF 200D 2696 FE0F - \; fully-qualified # 👩🏿‍⚖️ E4.0 woman judge: dark - skin tone\n1F469 1F3FF 200D 2696 \; minimally-qualif - ied # 👩🏿‍⚖ E4.0 woman judge: dark skin tone\n1F9D1 200D 1F33E - \; fully-qualified # 🧑‍🌾 E12.1 farmer\ - n1F9D1 1F3FB 200D 1F33E \; fully-qualified # 🧑 - 🏻‍🌾 E12.1 farmer: light skin tone\n1F9D1 1F3FC 200D 1F33E - \; fully-qualified # 🧑🏼‍🌾 E12.1 farmer: medium- - light skin tone\n1F9D1 1F3FD 200D 1F33E \; fully-quali - fied # 🧑🏽‍🌾 E12.1 farmer: medium skin tone\n1F9D1 1F3FE 200 - D 1F33E \; fully-qualified # 🧑🏾‍🌾 E12.1 - farmer: medium-dark skin tone\n1F9D1 1F3FF 200D 1F33E - \; fully-qualified # 🧑🏿‍🌾 E12.1 farmer: dark skin tone\n1F - 468 200D 1F33E \; fully-qualified # 👨‍ - 🌾 E4.0 man farmer\n1F468 1F3FB 200D 1F33E \; fully- - qualified # 👨🏻‍🌾 E4.0 man farmer: light skin tone\n1F468 1F - 3FC 200D 1F33E \; fully-qualified # 👨🏼‍ - 🌾 E4.0 man farmer: medium-light skin tone\n1F468 1F3FD 200D 1F33E - \; fully-qualified # 👨🏽‍🌾 E4.0 man farmer: m - edium skin tone\n1F468 1F3FE 200D 1F33E \; fully-quali - fied # 👨🏾‍🌾 E4.0 man farmer: medium-dark skin tone\n1F468 1 - F3FF 200D 1F33E \; fully-qualified # 👨🏿‍ - 🌾 E4.0 man farmer: dark skin tone\n1F469 200D 1F33E - \; fully-qualified # 👩‍🌾 E4.0 woman farmer\n1F469 1F3FB - 200D 1F33E \; fully-qualified # 👩🏻‍🌾 E - 4.0 woman farmer: light skin tone\n1F469 1F3FC 200D 1F33E - \; fully-qualified # 👩🏼‍🌾 E4.0 woman farmer: medium-lig - ht skin tone\n1F469 1F3FD 200D 1F33E \; fully-qualifie - d # 👩🏽‍🌾 E4.0 woman farmer: medium skin tone\n1F469 1F3FE 2 - 00D 1F33E \; fully-qualified # 👩🏾‍🌾 E4. - 0 woman farmer: medium-dark skin tone\n1F469 1F3FF 200D 1F33E - \; fully-qualified # 👩🏿‍🌾 E4.0 woman farmer: dark s - kin tone\n1F9D1 200D 1F373 \; fully-qualified - # 🧑‍🍳 E12.1 cook\n1F9D1 1F3FB 200D 1F373 \; - fully-qualified # 🧑🏻‍🍳 E12.1 cook: light skin tone\n1F9D1 1 - F3FC 200D 1F373 \; fully-qualified # 🧑🏼‍ - 🍳 E12.1 cook: medium-light skin tone\n1F9D1 1F3FD 200D 1F373 - \; fully-qualified # 🧑🏽‍🍳 E12.1 cook: medium skin - tone\n1F9D1 1F3FE 200D 1F373 \; fully-qualified # - 🧑🏾‍🍳 E12.1 cook: medium-dark skin tone\n1F9D1 1F3FF 200D 1F373 - \; fully-qualified # 🧑🏿‍🍳 E12.1 cook: - dark skin tone\n1F468 200D 1F373 \; fully-qualif - ied # 👨‍🍳 E4.0 man cook\n1F468 1F3FB 200D 1F373 - \; fully-qualified # 👨🏻‍🍳 E4.0 man cook: light skin t - one\n1F468 1F3FC 200D 1F373 \; fully-qualified # - 👨🏼‍🍳 E4.0 man cook: medium-light skin tone\n1F468 1F3FD 200D 1F - 373 \; fully-qualified # 👨🏽‍🍳 E4.0 man - cook: medium skin tone\n1F468 1F3FE 200D 1F373 \; full - y-qualified # 👨🏾‍🍳 E4.0 man cook: medium-dark skin tone\n1F - 468 1F3FF 200D 1F373 \; fully-qualified # 👨🏿 - ‍🍳 E4.0 man cook: dark skin tone\n1F469 200D 1F373 - \; fully-qualified # 👩‍🍳 E4.0 woman cook\n1F469 1F3FB - 200D 1F373 \; fully-qualified # 👩🏻‍🍳 E4 - .0 woman cook: light skin tone\n1F469 1F3FC 200D 1F373 - \; fully-qualified # 👩🏼‍🍳 E4.0 woman cook: medium-light sk - in tone\n1F469 1F3FD 200D 1F373 \; fully-qualified - # 👩🏽‍🍳 E4.0 woman cook: medium skin tone\n1F469 1F3FE 200D 1F3 - 73 \; fully-qualified # 👩🏾‍🍳 E4.0 woman - cook: medium-dark skin tone\n1F469 1F3FF 200D 1F373 \ - ; fully-qualified # 👩🏿‍🍳 E4.0 woman cook: dark skin tone\n1 - F9D1 200D 1F527 \; fully-qualified # 🧑‍ - 🔧 E12.1 mechanic\n1F9D1 1F3FB 200D 1F527 \; fully-q - ualified # 🧑🏻‍🔧 E12.1 mechanic: light skin tone\n1F9D1 1F3F - C 200D 1F527 \; fully-qualified # 🧑🏼‍🔧 - E12.1 mechanic: medium-light skin tone\n1F9D1 1F3FD 200D 1F527 - \; fully-qualified # 🧑🏽‍🔧 E12.1 mechanic: medium s - kin tone\n1F9D1 1F3FE 200D 1F527 \; fully-qualified - # 🧑🏾‍🔧 E12.1 mechanic: medium-dark skin tone\n1F9D1 1F3FF 200 - D 1F527 \; fully-qualified # 🧑🏿‍🔧 E12.1 - mechanic: dark skin tone\n1F468 200D 1F527 \; f - ully-qualified # 👨‍🔧 E4.0 man mechanic\n1F468 1F3FB 200D 1F527 - \; fully-qualified # 👨🏻‍🔧 E4.0 man mec - hanic: light skin tone\n1F468 1F3FC 200D 1F527 \; full - y-qualified # 👨🏼‍🔧 E4.0 man mechanic: medium-light skin ton - e\n1F468 1F3FD 200D 1F527 \; fully-qualified # - 👨🏽‍🔧 E4.0 man mechanic: medium skin tone\n1F468 1F3FE 200D 1F52 - 7 \; fully-qualified # 👨🏾‍🔧 E4.0 man me - chanic: medium-dark skin tone\n1F468 1F3FF 200D 1F527 - \; fully-qualified # 👨🏿‍🔧 E4.0 man mechanic: dark skin tone - \n1F469 200D 1F527 \; fully-qualified # 👩 - ‍🔧 E4.0 woman mechanic\n1F469 1F3FB 200D 1F527 \; - fully-qualified # 👩🏻‍🔧 E4.0 woman mechanic: light skin ton - e\n1F469 1F3FC 200D 1F527 \; fully-qualified # � - �🏼‍🔧 E4.0 woman mechanic: medium-light skin tone\n1F469 1F3FD 20 - 0D 1F527 \; fully-qualified # 👩🏽‍🔧 E4.0 - woman mechanic: medium skin tone\n1F469 1F3FE 200D 1F527 - \; fully-qualified # 👩🏾‍🔧 E4.0 woman mechanic: medium-d - ark skin tone\n1F469 1F3FF 200D 1F527 \; fully-qualifi - ed # 👩🏿‍🔧 E4.0 woman mechanic: dark skin tone\n1F9D1 200D 1 - F3ED \; fully-qualified # 🧑‍🏭 E12.1 - factory worker\n1F9D1 1F3FB 200D 1F3ED \; fully-qualif - ied # 🧑🏻‍🏭 E12.1 factory worker: light skin tone\n1F9D1 1F3 - FC 200D 1F3ED \; fully-qualified # 🧑🏼‍🏭 - E12.1 factory worker: medium-light skin tone\n1F9D1 1F3FD 200D 1F3ED - \; fully-qualified # 🧑🏽‍🏭 E12.1 factory wor - ker: medium skin tone\n1F9D1 1F3FE 200D 1F3ED \; fully - -qualified # 🧑🏾‍🏭 E12.1 factory worker: medium-dark skin to - ne\n1F9D1 1F3FF 200D 1F3ED \; fully-qualified # - 🧑🏿‍🏭 E12.1 factory worker: dark skin tone\n1F468 200D 1F3ED - \; fully-qualified # 👨‍🏭 E4.0 man facto - ry worker\n1F468 1F3FB 200D 1F3ED \; fully-qualified - # 👨🏻‍🏭 E4.0 man factory worker: light skin tone\n1F468 1F3FC - 200D 1F3ED \; fully-qualified # 👨🏼‍🏭 E - 4.0 man factory worker: medium-light skin tone\n1F468 1F3FD 200D 1F3ED - \; fully-qualified # 👨🏽‍�� E4.0 man facto - ry worker: medium skin tone\n1F468 1F3FE 200D 1F3ED \; - fully-qualified # 👨🏾‍🏭 E4.0 man factory worker: medium-dar - k skin tone\n1F468 1F3FF 200D 1F3ED \; fully-qualified - # 👨🏿‍🏭 E4.0 man factory worker: dark skin tone\n1F469 200D - 1F3ED \; fully-qualified # 👩‍🏭 E4.0 - woman factory worker\n1F469 1F3FB 200D 1F3ED \; fully - -qualified # 👩🏻‍🏭 E4.0 woman factory worker: light skin ton - e\n1F469 1F3FC 200D 1F3ED \; fully-qualified # - 👩🏼‍🏭 E4.0 woman factory worker: medium-light skin tone\n1F469 1 - F3FD 200D 1F3ED \; fully-qualified # 👩🏽‍ - 🏭 E4.0 woman factory worker: medium skin tone\n1F469 1F3FE 200D 1F3ED - \; fully-qualified # 👩🏾‍🏭 E4.0 woman fac - tory worker: medium-dark skin tone\n1F469 1F3FF 200D 1F3ED - \; fully-qualified # 👩🏿‍🏭 E4.0 woman factory worker: d - ark skin tone\n1F9D1 200D 1F4BC \; fully-qualifi - ed # 🧑‍💼 E12.1 office worker\n1F9D1 1F3FB 200D 1F4BC - \; fully-qualified # 🧑🏻‍💼 E12.1 office worker: l - ight skin tone\n1F9D1 1F3FC 200D 1F4BC \; fully-qualif - ied # 🧑🏼‍💼 E12.1 office worker: medium-light skin tone\n1F9 - D1 1F3FD 200D 1F4BC \; fully-qualified # 🧑🏽 - ‍💼 E12.1 office worker: medium skin tone\n1F9D1 1F3FE 200D 1F4BC - \; fully-qualified # 🧑🏾‍💼 E12.1 office work - er: medium-dark skin tone\n1F9D1 1F3FF 200D 1F4BC \; f - ully-qualified # 🧑🏿‍💼 E12.1 office worker: dark skin tone\n - 1F468 200D 1F4BC \; fully-qualified # 👨 - ‍💼 E4.0 man office worker\n1F468 1F3FB 200D 1F4BC - \; fully-qualified # 👨🏻‍💼 E4.0 man office worker: light sk - in tone\n1F468 1F3FC 200D 1F4BC \; fully-qualified - # 👨🏼‍💼 E4.0 man office worker: medium-light skin tone\n1F468 1 - F3FD 200D 1F4BC \; fully-qualified # 👨🏽‍ - 💼 E4.0 man office worker: medium skin tone\n1F468 1F3FE 200D 1F4BC - \; fully-qualified # 👨🏾‍💼 E4.0 man office w - orker: medium-dark skin tone\n1F468 1F3FF 200D 1F4BC \ - ; fully-qualified # 👨🏿‍💼 E4.0 man office worker: dark skin - tone\n1F469 200D 1F4BC \; fully-qualified # - 👩‍💼 E4.0 woman office worker\n1F469 1F3FB 200D 1F4BC - \; fully-qualified # 👩🏻‍💼 E4.0 woman office worker: - light skin tone\n1F469 1F3FC 200D 1F4BC \; fully-quali - fied # 👩🏼‍💼 E4.0 woman office worker: medium-light skin ton - e\n1F469 1F3FD 200D 1F4BC \; fully-qualified # - 👩🏽‍💼 E4.0 woman office worker: medium skin tone\n1F469 1F3FE 20 - 0D 1F4BC \; fully-qualified # 👩🏾‍💼 E4.0 - woman office worker: medium-dark skin tone\n1F469 1F3FF 200D 1F4BC - \; fully-qualified # 👩🏿‍💼 E4.0 woman office w - orker: dark skin tone\n1F9D1 200D 1F52C \; fully - -qualified # 🧑‍🔬 E12.1 scientist\n1F9D1 1F3FB 200D 1F52C - \; fully-qualified # 🧑🏻‍🔬 E12.1 scientist: l - ight skin tone\n1F9D1 1F3FC 200D 1F52C \; fully-qualif - ied # 🧑🏼‍🔬 E12.1 scientist: medium-light skin tone\n1F9D1 1 - F3FD 200D 1F52C \; fully-qualified # 🧑🏽‍ - 🔬 E12.1 scientist: medium skin tone\n1F9D1 1F3FE 200D 1F52C - \; fully-qualified # 🧑🏾‍🔬 E12.1 scientist: medium- - dark skin tone\n1F9D1 1F3FF 200D 1F52C \; fully-qualif - ied # 🧑🏿‍🔬 E12.1 scientist: dark skin tone\n1F468 200D 1F52 - C \; fully-qualified # 👨‍🔬 E4.0 man - scientist\n1F468 1F3FB 200D 1F52C \; fully-qualified - # 👨🏻‍🔬 E4.0 man scientist: light skin tone\n1F468 1F3FC 200D - 1F52C \; fully-qualified # 👨🏼‍🔬 E4.0 m - an scientist: medium-light skin tone\n1F468 1F3FD 200D 1F52C - \; fully-qualified # 👨🏽‍🔬 E4.0 man scientist: medium - skin tone\n1F468 1F3FE 200D 1F52C \; fully-qualified - # 👨🏾‍🔬 E4.0 man scientist: medium-dark skin tone\n1F468 1F3 - FF 200D 1F52C \; fully-qualified # 👨🏿‍🔬 - E4.0 man scientist: dark skin tone\n1F469 200D 1F52C - \; fully-qualified # 👩‍🔬 E4.0 woman scientist\n1F469 1F3 - FB 200D 1F52C \; fully-qualified # 👩��‍ - 🔬 E4.0 woman scientist: light skin tone\n1F469 1F3FC 200D 1F52C - \; fully-qualified # 👩🏼‍🔬 E4.0 woman scientist - : medium-light skin tone\n1F469 1F3FD 200D 1F52C \; fu - lly-qualified # 👩🏽‍🔬 E4.0 woman scientist: medium skin tone - \n1F469 1F3FE 200D 1F52C \; fully-qualified # 👩 - 🏾‍🔬 E4.0 woman scientist: medium-dark skin tone\n1F469 1F3FF 200D - 1F52C \; fully-qualified # 👩🏿‍🔬 E4.0 wo - man scientist: dark skin tone\n1F9D1 200D 1F4BB - \; fully-qualified # 🧑‍💻 E12.1 technologist\n1F9D1 1F3FB 200D - 1F4BB \; fully-qualified # 🧑🏻‍💻 E12.1 t - echnologist: light skin tone\n1F9D1 1F3FC 200D 1F4BB \ - ; fully-qualified # 🧑🏼‍💻 E12.1 technologist: medium-light s - kin tone\n1F9D1 1F3FD 200D 1F4BB \; fully-qualified - # 🧑🏽‍💻 E12.1 technologist: medium skin tone\n1F9D1 1F3FE 200D - 1F4BB \; fully-qualified # 🧑🏾‍💻 E12.1 - technologist: medium-dark skin tone\n1F9D1 1F3FF 200D 1F4BB - \; fully-qualified # 🧑🏿‍💻 E12.1 technologist: dark sk - in tone\n1F468 200D 1F4BB \; fully-qualified - # 👨‍💻 E4.0 man technologist\n1F468 1F3FB 200D 1F4BB - \; fully-qualified # 👨🏻‍💻 E4.0 man technologist: lig - ht skin tone\n1F468 1F3FC 200D 1F4BB \; fully-qualifie - d # 👨🏼‍💻 E4.0 man technologist: medium-light skin tone\n1F4 - 68 1F3FD 200D 1F4BB \; fully-qualified # 👨🏽 - ‍💻 E4.0 man technologist: medium skin tone\n1F468 1F3FE 200D 1F4BB - \; fully-qualified # 👨🏾‍💻 E4.0 man techno - logist: medium-dark skin tone\n1F468 1F3FF 200D 1F4BB - \; fully-qualified # 👨🏿‍💻 E4.0 man technologist: dark skin - tone\n1F469 200D 1F4BB \; fully-qualified # - 👩‍💻 E4.0 woman technologist\n1F469 1F3FB 200D 1F4BB - \; fully-qualified # 👩🏻‍💻 E4.0 woman technologist: li - ght skin tone\n1F469 1F3FC 200D 1F4BB \; fully-qualifi - ed # 👩🏼‍💻 E4.0 woman technologist: medium-light skin tone\n - 1F469 1F3FD 200D 1F4BB \; fully-qualified # 👩 - 🏽‍💻 E4.0 woman technologist: medium skin tone\n1F469 1F3FE 200D 1F - 4BB \; fully-qualified # 👩🏾‍💻 E4.0 woma - n technologist: medium-dark skin tone\n1F469 1F3FF 200D 1F4BB - \; fully-qualified # 👩🏿‍💻 E4.0 woman technologist: - dark skin tone\n1F9D1 200D 1F3A4 \; fully-qualif - ied # 🧑‍🎤 E12.1 singer\n1F9D1 1F3FB 200D 1F3A4 - \; fully-qualified # 🧑🏻‍🎤 E12.1 singer: light skin ton - e\n1F9D1 1F3FC 200D 1F3A4 \; fully-qualified # - 🧑🏼‍�� E12.1 singer: medium-light skin tone\n1F9D1 1F3FD 200D 1 - F3A4 \; fully-qualified # 🧑🏽‍🎤 E12.1 si - nger: medium skin tone\n1F9D1 1F3FE 200D 1F3A4 \; full - y-qualified # 🧑🏾‍🎤 E12.1 singer: medium-dark skin tone\n1F9 - D1 1F3FF 200D 1F3A4 \; fully-qualified # 🧑🏿 - ‍🎤 E12.1 singer: dark skin tone\n1F468 200D 1F3A4 - \; fully-qualified # 👨‍🎤 E4.0 man singer\n1F468 1F3FB 2 - 00D 1F3A4 \; fully-qualified # 👨🏻‍🎤 E4. - 0 man singer: light skin tone\n1F468 1F3FC 200D 1F3A4 - \; fully-qualified # 👨🏼‍🎤 E4.0 man singer: medium-light ski - n tone\n1F468 1F3FD 200D 1F3A4 \; fully-qualified - # 👨🏽‍🎤 E4.0 man singer: medium skin tone\n1F468 1F3FE 200D 1F3A - 4 \; fully-qualified # 👨🏾‍🎤 E4.0 man si - nger: medium-dark skin tone\n1F468 1F3FF 200D 1F3A4 \; - fully-qualified # 👨🏿‍🎤 E4.0 man singer: dark skin tone\n1F - 469 200D 1F3A4 \; fully-qualified # 👩‍ - 🎤 E4.0 woman singer\n1F469 1F3FB 200D 1F3A4 \; full - y-qualified # 👩🏻‍🎤 E4.0 woman singer: light skin tone\n1F46 - 9 1F3FC 200D 1F3A4 \; fully-qualified # 👩🏼 - ‍🎤 E4.0 woman singer: medium-light skin tone\n1F469 1F3FD 200D 1F3A4 - \; fully-qualified # 👩🏽‍🎤 E4.0 woman si - nger: medium skin tone\n1F469 1F3FE 200D 1F3A4 \; full - y-qualified # 👩🏾‍🎤 E4.0 woman singer: medium-dark skin tone - \n1F469 1F3FF 200D 1F3A4 \; fully-qualified # 👩 - 🏿‍�� E4.0 woman singer: dark skin tone\n1F9D1 200D 1F3A8 - \; fully-qualified # 🧑‍🎨 E12.1 artist\n1F9D1 - 1F3FB 200D 1F3A8 \; fully-qualified # 🧑🏻‍ - 🎨 E12.1 artist: light skin tone\n1F9D1 1F3FC 200D 1F3A8 - \; fully-qualified # 🧑🏼‍🎨 E12.1 artist: medium-light s - kin tone\n1F9D1 1F3FD 200D 1F3A8 \; fully-qualified - # 🧑🏽‍🎨 E12.1 artist: medium skin tone\n1F9D1 1F3FE 200D 1F3A8 - \; fully-qualified # 🧑��‍🎨 E12.1 arti - st: medium-dark skin tone\n1F9D1 1F3FF 200D 1F3A8 \; f - ully-qualified # 🧑🏿‍🎨 E12.1 artist: dark skin tone\n1F468 2 - 00D 1F3A8 \; fully-qualified # 👨‍🎨 E - 4.0 man artist\n1F468 1F3FB 200D 1F3A8 \; fully-qualif - ied # 👨🏻‍🎨 E4.0 man artist: light skin tone\n1F468 1F3FC 20 - 0D 1F3A8 \; fully-qualified # 👨🏼‍🎨 E4.0 - man artist: medium-light skin tone\n1F468 1F3FD 200D 1F3A8 - \; fully-qualified # 👨🏽‍🎨 E4.0 man artist: medium ski - n tone\n1F468 1F3FE 200D 1F3A8 \; fully-qualified - # 👨🏾‍🎨 E4.0 man artist: medium-dark skin tone\n1F468 1F3FF 200D - 1F3A8 \; fully-qualified # 👨🏿‍🎨 E4.0 m - an artist: dark skin tone\n1F469 200D 1F3A8 \; f - ully-qualified # 👩‍🎨 E4.0 woman artist\n1F469 1F3FB 200D 1F3A8 - \; fully-qualified # 👩🏻‍🎨 E4.0 woman a - rtist: light skin tone\n1F469 1F3FC 200D 1F3A8 \; full - y-qualified # 👩🏼‍🎨 E4.0 woman artist: medium-light skin ton - e\n1F469 1F3FD 200D 1F3A8 \; fully-qualified # - 👩🏽‍�� E4.0 woman artist: medium skin tone\n1F469 1F3FE 200D 1F - 3A8 \; fully-qualified # 👩🏾‍🎨 E4.0 woma - n artist: medium-dark skin tone\n1F469 1F3FF 200D 1F3A8 - \; fully-qualified # 👩🏿‍🎨 E4.0 woman artist: dark skin to - ne\n1F9D1 200D 2708 FE0F \; fully-qualified # - 🧑‍✈️ E12.1 pilot\n1F9D1 200D 2708 \; m - inimally-qualified # 🧑‍✈ E12.1 pilot\n1F9D1 1F3FB 200D 2708 FE0F - \; fully-qualified # 🧑🏻‍✈️ E12.1 pilot: ligh - t skin tone\n1F9D1 1F3FB 200D 2708 \; minimally-quali - fied # 🧑🏻‍✈ E12.1 pilot: light skin tone\n1F9D1 1F3FC 200D 2708 - FE0F \; fully-qualified # 🧑🏼‍✈️ E12.1 pilo - t: medium-light skin tone\n1F9D1 1F3FC 200D 2708 \; m - inimally-qualified # 🧑🏼‍✈ E12.1 pilot: medium-light skin tone\n1 - F9D1 1F3FD 200D 2708 FE0F \; fully-qualified # 🧑 - 🏽‍✈️ E12.1 pilot: medium skin tone\n1F9D1 1F3FD 200D 2708 - \; minimally-qualified # 🧑🏽‍✈ E12.1 pilot: medium - skin tone\n1F9D1 1F3FE 200D 2708 FE0F \; fully-qualified - # 🧑🏾‍✈️ E12.1 pilot: medium-dark skin tone\n1F9D1 1F3FE 200 - D 2708 \; minimally-qualified # 🧑🏾‍✈ E12.1 - pilot: medium-dark skin tone\n1F9D1 1F3FF 200D 2708 FE0F \ - ; fully-qualified # 🧑🏿‍✈️ E12.1 pilot: dark skin tone\n1F9 - D1 1F3FF 200D 2708 \; minimally-qualified # 🧑🏿 - ‍✈ E12.1 pilot: dark skin tone\n1F468 200D 2708 FE0F - \; fully-qualified # 👨‍✈️ E4.0 man pilot\n1F468 200D 270 - 8 \; minimally-qualified # 👨‍✈ E4.0 man - pilot\n1F468 1F3FB 200D 2708 FE0F \; fully-qualified # - 👨🏻‍✈️ E4.0 man pilot: light skin tone\n1F468 1F3FB 200D 2708 - \; minimally-qualified # 👨🏻‍✈ E4.0 man pilo - t: light skin tone\n1F468 1F3FC 200D 2708 FE0F \; fully-qu - alified # 👨🏼‍✈️ E4.0 man pilot: medium-light skin tone\n1F - 468 1F3FC 200D 2708 \; minimally-qualified # 👨🏼 - ‍✈ E4.0 man pilot: medium-light skin tone\n1F468 1F3FD 200D 2708 FE0F - \; fully-qualified # 👨🏽‍✈️ E4.0 man pilot: - medium skin tone\n1F468 1F3FD 200D 2708 \; minimally - -qualified # 👨🏽‍✈ E4.0 man pilot: medium skin tone\n1F468 1F3FE - 200D 2708 FE0F \; fully-qualified # 👨🏾‍✈️ - E4.0 man pilot: medium-dark skin tone\n1F468 1F3FE 200D 2708 - \; minimally-qualified # 👨🏾‍✈ E4.0 man pilot: medium-dar - k skin tone\n1F468 1F3FF 200D 2708 FE0F \; fully-qualified - # 👨🏿‍✈️ E4.0 man pilot: dark skin tone\n1F468 1F3FF 200D - 2708 \; minimally-qualified # 👨🏿‍✈ E4.0 man - pilot: dark skin tone\n1F469 200D 2708 FE0F \; full - y-qualified # 👩‍✈️ E4.0 woman pilot\n1F469 200D 2708 - \; minimally-qualified # 👩‍✈ E4.0 woman pilot\n1 - F469 1F3FB 200D 2708 FE0F \; fully-qualified # 👩 - 🏻‍✈️ E4.0 woman pilot: light skin tone\n1F469 1F3FB 200D 2708 - \; minimally-qualified # 👩🏻‍✈ E4.0 woman pilot - : light skin tone\n1F469 1F3FC 200D 2708 FE0F \; fully-qua - lified # 👩🏼‍✈️ E4.0 woman pilot: medium-light skin tone\n1 - F469 1F3FC 200D 2708 \; minimally-qualified # 👩 - 🏼‍✈ E4.0 woman pilot: medium-light skin tone\n1F469 1F3FD 200D 2708 - FE0F \; fully-qualified # 👩🏽‍✈️ E4.0 woma - n pilot: medium skin tone\n1F469 1F3FD 200D 2708 \; m - inimally-qualified # 👩🏽‍✈ E4.0 woman pilot: medium skin tone\n1F - 469 1F3FE 200D 2708 FE0F \; fully-qualified # 👩🏾 - ‍✈️ E4.0 woman pilot: medium-dark skin tone\n1F469 1F3FE 200D 2708 - \; minimally-qualified # 👩🏾‍✈ E4.0 woman pil - ot: medium-dark skin tone\n1F469 1F3FF 200D 2708 FE0F \; f - ully-qualified # 👩🏿‍✈️ E4.0 woman pilot: dark skin tone\n1 - F469 1F3FF 200D 2708 \; minimally-qualified # 👩 - 🏿‍✈ E4.0 woman pilot: dark skin tone\n1F9D1 200D 1F680 - \; fully-qualified # 🧑‍🚀 E12.1 astronaut\n1F9D1 - 1F3FB 200D 1F680 \; fully-qualified # 🧑🏻‍ - 🚀 E12.1 astronaut: light skin tone\n1F9D1 1F3FC 200D 1F680 - \; fully-qualified # 🧑🏼‍🚀 E12.1 astronaut: medium-l - ight skin tone\n1F9D1 1F3FD 200D 1F680 \; fully-qualif - ied # 🧑🏽‍🚀 E12.1 astronaut: medium skin tone\n1F9D1 1F3FE 2 - 00D 1F680 \; fully-qualified # 🧑🏾‍🚀 E12 - .1 astronaut: medium-dark skin tone\n1F9D1 1F3FF 200D 1F680 - \; fully-qualified # 🧑🏿‍🚀 E12.1 astronaut: dark skin - tone\n1F468 200D 1F680 \; fully-qualified # - 👨‍🚀 E4.0 man astronaut\n1F468 1F3FB 200D 1F680 - \; fully-qualified # 👨🏻‍🚀 E4.0 man astronaut: light skin t - one\n1F468 1F3FC 200D 1F680 \; fully-qualified # - 👨🏼‍🚀 E4.0 man astronaut: medium-light skin tone\n1F468 1F3FD 20 - 0D 1F680 \; fully-qualified # 👨🏽‍🚀 E4.0 - man astronaut: medium skin tone\n1F468 1F3FE 200D 1F680 - \; fully-qualified # 👨🏾‍🚀 E4.0 man astronaut: medium-dar - k skin tone\n1F468 1F3FF 200D 1F680 \; fully-qualified - # 👨🏿‍🚀 E4.0 man astronaut: dark skin tone\n1F469 200D 1F68 - 0 \; fully-qualified # 👩‍🚀 E4.0 woma - n astronaut\n1F469 1F3FB 200D 1F680 \; fully-qualified - # 👩🏻‍🚀 E4.0 woman astronaut: light skin tone\n1F469 1F3FC - 200D 1F680 \; fully-qualified # 👩🏼‍🚀 E4 - .0 woman astronaut: medium-light skin tone\n1F469 1F3FD 200D 1F680 - \; fully-qualified # 👩🏽‍🚀 E4.0 woman astronaut - : medium skin tone\n1F469 1F3FE 200D 1F680 \; fully-qu - alified # 👩🏾‍🚀 E4.0 woman astronaut: medium-dark skin tone\ - n1F469 1F3FF 200D 1F680 \; fully-qualified # 👩 - 🏿‍🚀 E4.0 woman astronaut: dark skin tone\n1F9D1 200D 1F692 - \; fully-qualified # 🧑‍🚒 E12.1 firefighter\ - n1F9D1 1F3FB 200D 1F692 \; fully-qualified # 🧑 - ��‍🚒 E12.1 firefighter: light skin tone\n1F9D1 1F3FC 200D 1F692 - \; fully-qualified # 🧑🏼‍🚒 E12.1 firefigh - ter: medium-light skin tone\n1F9D1 1F3FD 200D 1F692 \; - fully-qualified # 🧑🏽‍🚒 E12.1 firefighter: medium skin tone - \n1F9D1 1F3FE 200D 1F692 \; fully-qualified # 🧑 - 🏾‍🚒 E12.1 firefighter: medium-dark skin tone\n1F9D1 1F3FF 200D 1F6 - 92 \; fully-qualified # 🧑🏿‍🚒 E12.1 fire - fighter: dark skin tone\n1F468 200D 1F692 \; ful - ly-qualified # 👨‍🚒 E4.0 man firefighter\n1F468 1F3FB 200D 1F69 - 2 \; fully-qualified # 👨🏻‍🚒 E4.0 man fi - refighter: light skin tone\n1F468 1F3FC 200D 1F692 \; - fully-qualified # 👨🏼‍🚒 E4.0 man firefighter: medium-light s - kin tone\n1F468 1F3FD 200D 1F692 \; fully-qualified - # 👨🏽‍🚒 E4.0 man firefighter: medium skin tone\n1F468 1F3FE 20 - 0D 1F692 \; fully-qualified # 👨🏾‍🚒 E4.0 - man firefighter: medium-dark skin tone\n1F468 1F3FF 200D 1F692 - \; fully-qualified # 👨🏿‍🚒 E4.0 man firefighter: d - ark skin tone\n1F469 200D 1F692 \; fully-qualifi - ed # 👩‍🚒 E4.0 woman firefighter\n1F469 1F3FB 200D 1F692 - \; fully-qualified # 👩🏻‍🚒 E4.0 woman firefigh - ter: light skin tone\n1F469 1F3FC 200D 1F692 \; fully- - qualified # 👩🏼‍🚒 E4.0 woman firefighter: medium-light skin - tone\n1F469 1F3FD 200D 1F692 \; fully-qualified # - 👩🏽‍🚒 E4.0 woman firefighter: medium skin tone\n1F469 1F3FE 200D - 1F692 \; fully-qualified # 👩🏾‍🚒 E4.0 w - oman firefighter: medium-dark skin tone\n1F469 1F3FF 200D 1F692 - \; fully-qualified # 👩🏿‍🚒 E4.0 woman firefighter: - dark skin tone\n1F46E \; fully-quali - fied # 👮 E2.0 police officer\n1F46E 1F3FB - \; fully-qualified # 👮🏻 E2.0 police officer: light skin to - ne\n1F46E 1F3FC \; fully-qualified # - 👮🏼 E2.0 police officer: medium-light skin tone\n1F46E 1F3FD - \; fully-qualified # 👮🏽 E2.0 police offic - er: medium skin tone\n1F46E 1F3FE \; fully- - qualified # 👮🏾 E2.0 police officer: medium-dark skin tone\n1F46E - 1F3FF \; fully-qualified # 👮🏿 E2 - .0 police officer: dark skin tone\n1F46E 200D 2642 FE0F - \; fully-qualified # 👮‍♂️ E4.0 man police officer\n1F46E - 200D 2642 \; minimally-qualified # 👮‍♂ E - 4.0 man police officer\n1F46E 1F3FB 200D 2642 FE0F \; full - y-qualified # 👮🏻‍♂️ E4.0 man police officer: light skin to - ne\n1F46E 1F3FB 200D 2642 \; minimally-qualified # - 👮🏻‍♂ E4.0 man police officer: light skin tone\n1F46E 1F3FC 200D - 2642 FE0F \; fully-qualified # 👮🏼‍♂️ E4.0 - man police officer: medium-light skin tone\n1F46E 1F3FC 200D 2642 - \; minimally-qualified # 👮🏼‍♂ E4.0 man police offic - er: medium-light skin tone\n1F46E 1F3FD 200D 2642 FE0F \; - fully-qualified # 👮🏽‍♂️ E4.0 man police officer: medium sk - in tone\n1F46E 1F3FD 200D 2642 \; minimally-qualified - # 👮🏽‍♂ E4.0 man police officer: medium skin tone\n1F46E 1F3FE 2 - 00D 2642 FE0F \; fully-qualified # 👮🏾‍♂️ E - 4.0 man police officer: medium-dark skin tone\n1F46E 1F3FE 200D 2642 - \; minimally-qualified # 👮🏾‍♂ E4.0 man police of - ficer: medium-dark skin tone\n1F46E 1F3FF 200D 2642 FE0F \ - ; fully-qualified # 👮🏿‍♂️ E4.0 man police officer: dark sk - in tone\n1F46E 1F3FF 200D 2642 \; minimally-qualified - # 👮🏿‍♂ E4.0 man police officer: dark skin tone\n1F46E 200D 2640 - FE0F \; fully-qualified # 👮‍♀️ E4.0 wo - man police officer\n1F46E 200D 2640 \; minimall - y-qualified # 👮‍♀ E4.0 woman police officer\n1F46E 1F3FB 200D 2640 - FE0F \; fully-qualified # 👮🏻‍♀️ E4.0 woman - police officer: light skin tone\n1F46E 1F3FB 200D 2640 - \; minimally-qualified # 👮🏻‍♀ E4.0 woman police officer: ligh - t skin tone\n1F46E 1F3FC 200D 2640 FE0F \; fully-qualified - # 👮🏼‍♀️ E4.0 woman police officer: medium-light skin tone - \n1F46E 1F3FC 200D 2640 \; minimally-qualified # 👮 - 🏼‍♀ E4.0 woman police officer: medium-light skin tone\n1F46E 1F3FD - 200D 2640 FE0F \; fully-qualified # 👮🏽‍♀️ - E4.0 woman police officer: medium skin tone\n1F46E 1F3FD 200D 2640 - \; minimally-qualified # 👮🏽‍♀ E4.0 woman police of - ficer: medium skin tone\n1F46E 1F3FE 200D 2640 FE0F \; ful - ly-qualified # 👮🏾‍♀️ E4.0 woman police officer: medium-dar - k skin tone\n1F46E 1F3FE 200D 2640 \; minimally-quali - fied # 👮🏾‍♀ E4.0 woman police officer: medium-dark skin tone\n1F - 46E 1F3FF 200D 2640 FE0F \; fully-qualified # 👮🏿 - ‍♀️ E4.0 woman police officer: dark skin tone\n1F46E 1F3FF 200D 2640 - \; minimally-qualified # 👮🏿‍♀ E4.0 woman p - olice officer: dark skin tone\n1F575 FE0F - \; fully-qualified # 🕵️ E2.0 detective\n1F575 - \; unqualified # 🕵 E2.0 detective\n1F575 1F3F - B \; fully-qualified # 🕵🏻 E2.0 de - tective: light skin tone\n1F575 1F3FC \; fu - lly-qualified # 🕵🏼 E2.0 detective: medium-light skin tone\n1F575 - 1F3FD \; fully-qualified # 🕵🏽 E2 - .0 detective: medium skin tone\n1F575 1F3FE - \; fully-qualified # 🕵🏾 E2.0 detective: medium-dark skin tone\n - 1F575 1F3FF \; fully-qualified # 🕵 - 🏿 E2.0 detective: dark skin tone\n1F575 FE0F 200D 2642 FE0F - \; fully-qualified # 🕵️‍♂️ E4.0 man detective\n1F575 - 200D 2642 FE0F \; unqualified # 🕵‍♂ - ️ E4.0 man detective\n1F575 FE0F 200D 2642 \; unqu - alified # 🕵️‍♂ E4.0 man detective\n1F575 200D 2642 - \; unqualified # 🕵‍♂ E4.0 man detectiv - e\n1F575 1F3FB 200D 2642 FE0F \; fully-qualified # � - �🏻‍♂️ E4.0 man detective: light skin tone\n1F575 1F3FB 200D 264 - 2 \; minimally-qualified # 🕵🏻‍♂ E4.0 man de - tective: light skin tone\n1F575 1F3FC 200D 2642 FE0F \; fu - lly-qualified # ��🏼‍♂️ E4.0 man detective: medium-light s - kin tone\n1F575 1F3FC 200D 2642 \; minimally-qualifie - d # 🕵🏼‍♂ E4.0 man detective: medium-light skin tone\n1F575 1F3FD - 200D 2642 FE0F \; fully-qualified # 🕵🏽‍♂️ - E4.0 man detective: medium skin tone\n1F575 1F3FD 200D 2642 - \; minimally-qualified # 🕵🏽‍♂ E4.0 man detective: medium - skin tone\n1F575 1F3FE 200D 2642 FE0F \; fully-qualified - # 🕵🏾‍♂️ E4.0 man detective: medium-dark skin tone\n1F575 1 - F3FE 200D 2642 \; minimally-qualified # 🕵🏾‍ - ♂ E4.0 man detective: medium-dark skin tone\n1F575 1F3FF 200D 2642 FE0F - \; fully-qualified # 🕵🏿‍♂️ E4.0 man detect - ive: dark skin tone\n1F575 1F3FF 200D 2642 \; minimal - ly-qualified # 🕵🏿‍♂ E4.0 man detective: dark skin tone\n1F575 FE - 0F 200D 2640 FE0F \; fully-qualified # 🕵️‍♀ - ️ E4.0 woman detective\n1F575 200D 2640 FE0F \; un - qualified # 🕵‍♀️ E4.0 woman detective\n1F575 FE0F 200D 26 - 40 \; unqualified # 🕵️‍♀ E4.0 woman - detective\n1F575 200D 2640 \; unqualified - # 🕵‍♀ E4.0 woman detective\n1F575 1F3FB 200D 2640 FE0F - \; fully-qualified # 🕵🏻‍♀️ E4.0 woman detective: l - ight skin tone\n1F575 1F3FB 200D 2640 \; minimally-qu - alified # ��🏻‍♀ E4.0 woman detective: light skin tone\n1F575 1F - 3FC 200D 2640 FE0F \; fully-qualified # 🕵🏼‍♀ - ️ E4.0 woman detective: medium-light skin tone\n1F575 1F3FC 200D 2640 - \; minimally-qualified # 🕵🏼‍♀ E4.0 woman dete - ctive: medium-light skin tone\n1F575 1F3FD 200D 2640 FE0F - \; fully-qualified # 🕵🏽‍♀️ E4.0 woman detective: medium sk - in tone\n1F575 1F3FD 200D 2640 \; minimally-qualified - # 🕵🏽‍♀ E4.0 woman detective: medium skin tone\n1F575 1F3FE 200D - 2640 FE0F \; fully-qualified # 🕵🏾‍♀️ E4.0 - woman detective: medium-dark skin tone\n1F575 1F3FE 200D 2640 - \; minimally-qualified # 🕵🏾‍♀ E4.0 woman detective: me - dium-dark skin tone\n1F575 1F3FF 200D 2640 FE0F \; fully-q - ualified # 🕵🏿‍♀️ E4.0 woman detective: dark skin tone\n1F5 - 75 1F3FF 200D 2640 \; minimally-qualified # 🕵🏿 - ‍♀ E4.0 woman detective: dark skin tone\n1F482 - \; fully-qualified # 💂 E2.0 guard\n1F482 1F3FB - \; fully-qualified # 💂🏻 E2.0 guard: lig - ht skin tone\n1F482 1F3FC \; fully-qualifie - d # 💂🏼 E2.0 guard: medium-light skin tone\n1F482 1F3FD - \; fully-qualified # ��🏽 E2.0 guard: medi - um skin tone\n1F482 1F3FE \; fully-qualifie - d # 💂🏾 E2.0 guard: medium-dark skin tone\n1F482 1F3FF - \; fully-qualified # 💂🏿 E2.0 guard: dark sk - in tone\n1F482 200D 2642 FE0F \; fully-qualified - # 💂‍♂️ E4.0 man guard\n1F482 200D 2642 - \; minimally-qualified # 💂‍♂ E4.0 man guard\n1F482 1F3FB 200D 264 - 2 FE0F \; fully-qualified # 💂🏻‍♂️ E4.0 man - guard: light skin tone\n1F482 1F3FB 200D 2642 \; min - imally-qualified # ��🏻‍♂ E4.0 man guard: light skin tone\n1F482 - 1F3FC 200D 2642 FE0F \; fully-qualified # 💂🏼‍ - ♂️ E4.0 man guard: medium-light skin tone\n1F482 1F3FC 200D 2642 - \; minimally-qualified # 💂🏼‍♂ E4.0 man guard: me - dium-light skin tone\n1F482 1F3FD 200D 2642 FE0F \; fully- - qualified # 💂🏽‍♂️ E4.0 man guard: medium skin tone\n1F482 - 1F3FD 200D 2642 \; minimally-qualified # 💂🏽‍ - ♂ E4.0 man guard: medium skin tone\n1F482 1F3FE 200D 2642 FE0F - \; fully-qualified # 💂🏾‍♂️ E4.0 man guard: medium-d - ark skin tone\n1F482 1F3FE 200D 2642 \; minimally-qua - lified # 💂��‍♂ E4.0 man guard: medium-dark skin tone\n1F482 1F3 - FF 200D 2642 FE0F \; fully-qualified # 💂🏿‍♂ - ️ E4.0 man guard: dark skin tone\n1F482 1F3FF 200D 2642 - \; minimally-qualified # 💂🏿‍♂ E4.0 man guard: dark skin ton - e\n1F482 200D 2640 FE0F \; fully-qualified # - 💂‍♀️ E4.0 woman guard\n1F482 200D 2640 - \; minimally-qualified # 💂‍♀ E4.0 woman guard\n1F482 1F3FB 200D 26 - 40 FE0F \; fully-qualified # 💂🏻‍♀️ E4.0 wo - man guard: light skin tone\n1F482 1F3FB 200D 2640 \; - minimally-qualified # 💂🏻‍♀ E4.0 woman guard: light skin tone\n1F - 482 1F3FC 200D 2640 FE0F \; fully-qualified # 💂🏼 - ‍♀️ E4.0 woman guard: medium-light skin tone\n1F482 1F3FC 200D 2640 - \; minimally-qualified # 💂🏼‍♀ E4.0 woman gu - ard: medium-light skin tone\n1F482 1F3FD 200D 2640 FE0F \; - fully-qualified # 💂🏽‍♀️ E4.0 woman guard: medium skin ton - e\n1F482 1F3FD 200D 2640 \; minimally-qualified # - 💂🏽‍♀ E4.0 woman guard: medium skin tone\n1F482 1F3FE 200D 2640 F - E0F \; fully-qualified # 💂🏾‍♀️ E4.0 woman - guard: medium-dark skin tone\n1F482 1F3FE 200D 2640 \ - ; minimally-qualified # 💂🏾‍♀ E4.0 woman guard: medium-dark skin - tone\n1F482 1F3FF 200D 2640 FE0F \; fully-qualified # - 💂🏿‍♀️ E4.0 woman guard: dark skin tone\n1F482 1F3FF 200D 2640 - \; minimally-qualified # 💂🏿‍♀ E4.0 woman gu - ard: dark skin tone\n1F477 \; fully-q - ualified # 👷 E2.0 construction worker\n1F477 1F3FB - \; fully-qualified # 👷🏻 E2.0 construction worker: - light skin tone\n1F477 1F3FC \; fully-qual - ified # 👷🏼 E2.0 construction worker: medium-light skin tone\n1F4 - 77 1F3FD \; fully-qualified # 👷🏽 - E2.0 construction worker: medium skin tone\n1F477 1F3FE - \; fully-qualified # 👷🏾 E2.0 construction worker: m - edium-dark skin tone\n1F477 1F3FF \; fully- - qualified # 👷🏿 E2.0 construction worker: dark skin tone\n1F477 2 - 00D 2642 FE0F \; fully-qualified # 👷‍♂️ - E4.0 man construction worker\n1F477 200D 2642 - \; minimally-qualified # 👷‍♂ E4.0 man construction worker\n1F477 1F - 3FB 200D 2642 FE0F \; fully-qualified # 👷🏻‍♂ - ️ E4.0 man construction worker: light skin tone\n1F477 1F3FB 200D 2642 - \; minimally-qualified # 👷🏻‍♂ E4.0 man const - ruction worker: light skin tone\n1F477 1F3FC 200D 2642 FE0F - \; fully-qualified # 👷🏼‍♂️ E4.0 man construction worker: - medium-light skin tone\n1F477 1F3FC 200D 2642 \; min - imally-qualified # 👷🏼‍♂ E4.0 man construction worker: medium-lig - ht skin tone\n1F477 1F3FD 200D 2642 FE0F \; fully-qualifie - d # 👷🏽‍♂️ E4.0 man construction worker: medium skin tone\n - 1F477 1F3FD 200D 2642 \; minimally-qualified # 👷 - 🏽‍♂ E4.0 man construction worker: medium skin tone\n1F477 1F3FE 200 - D 2642 FE0F \; fully-qualified # 👷🏾‍♂️ E4. - 0 man construction worker: medium-dark skin tone\n1F477 1F3FE 200D 2642 - \; minimally-qualified # 👷🏾‍♂ E4.0 man constr - uction worker: medium-dark skin tone\n1F477 1F3FF 200D 2642 FE0F - \; fully-qualified # 👷🏿‍♂️ E4.0 man construction wo - rker: dark skin tone\n1F477 1F3FF 200D 2642 \; minima - lly-qualified # 👷🏿‍♂ E4.0 man construction worker: dark skin ton - e\n1F477 200D 2640 FE0F \; fully-qualified # - 👷‍♀️ E4.0 woman construction worker\n1F477 200D 2640 - \; minimally-qualified # 👷‍♀ E4.0 woman construction - worker\n1F477 1F3FB 200D 2640 FE0F \; fully-qualified - # 👷🏻‍♀️ E4.0 woman construction worker: light skin tone\n1F47 - 7 1F3FB 200D 2640 \; minimally-qualified # ��🏻 - ‍♀ E4.0 woman construction worker: light skin tone\n1F477 1F3FC 200D 2 - 640 FE0F \; fully-qualified # 👷🏼‍♀️ E4.0 w - oman construction worker: medium-light skin tone\n1F477 1F3FC 200D 2640 - \; minimally-qualified # 👷🏼‍♀ E4.0 woman cons - truction worker: medium-light skin tone\n1F477 1F3FD 200D 2640 FE0F - \; fully-qualified # 👷🏽‍♀️ E4.0 woman constructi - on worker: medium skin tone\n1F477 1F3FD 200D 2640 \; - minimally-qualified # 👷🏽‍♀ E4.0 woman construction worker: medi - um skin tone\n1F477 1F3FE 200D 2640 FE0F \; fully-qualifie - d # 👷🏾‍♀️ E4.0 woman construction worker: medium-dark skin - tone\n1F477 1F3FE 200D 2640 \; minimally-qualified # - 👷🏾‍♀ E4.0 woman construction worker: medium-dark skin tone\n1F4 - 77 1F3FF 200D 2640 FE0F \; fully-qualified # 👷🏿 - ‍♀️ E4.0 woman construction worker: dark skin tone\n1F477 1F3FF 200D - 2640 \; minimally-qualified # 👷🏿‍♀ E4.0 wo - man construction worker: dark skin tone\n1F934 - \; fully-qualified # 🤴 E4.0 prince\n1F934 1F3FB - \; fully-qualified # 🤴🏻 E4.0 prince: light - skin tone\n1F934 1F3FC \; fully-qualified - # 🤴🏼 E4.0 prince: medium-light skin tone\n1F934 1F3FD - \; fully-qualified # 🤴🏽 E4.0 prince: medium - skin tone\n1F934 1F3FE \; fully-qualified - # 🤴🏾 E4.0 prince: medium-dark skin tone\n1F934 1F3FF - \; fully-qualified # 🤴🏿 E4.0 prince: dark sk - in tone\n1F478 \; fully-qualified - # 👸 E2.0 princess\n1F478 1F3FB \; fully - -qualified # 👸🏻 E2.0 princess: light skin tone\n1F478 1F3FC - \; fully-qualified # 👸🏼 E2.0 princess - : medium-light skin tone\n1F478 1F3FD \; fu - lly-qualified # 👸🏽 E2.0 princess: medium skin tone\n1F478 1F3FE - \; fully-qualified # 👸🏾 E2.0 prin - cess: medium-dark skin tone\n1F478 1F3FF \; - fully-qualified # 👸🏿 E2.0 princess: dark skin tone\n1F473 - \; fully-qualified # 👳 E2.0 person - wearing turban\n1F473 1F3FB \; fully-qualif - ied # 👳🏻 E2.0 person wearing turban: light skin tone\n1F473 1F3F - C \; fully-qualified # 👳🏼 E2.0 pe - rson wearing turban: medium-light skin tone\n1F473 1F3FD - \; fully-qualified # 👳🏽 E2.0 person wearing turban - : medium skin tone\n1F473 1F3FE \; fully-qu - alified # 👳🏾 E2.0 person wearing turban: medium-dark skin tone\n - 1F473 1F3FF \; fully-qualified # 👳 - 🏿 E2.0 person wearing turban: dark skin tone\n1F473 200D 2642 FE0F - \; fully-qualified # 👳‍♂️ E4.0 man wearing - turban\n1F473 200D 2642 \; minimally-qualified - # 👳‍♂ E4.0 man wearing turban\n1F473 1F3FB 200D 2642 FE0F - \; fully-qualified # 👳🏻‍♂️ E4.0 man wearing turban: - light skin tone\n1F473 1F3FB 200D 2642 \; minimally- - qualified # 👳🏻‍♂ E4.0 man wearing turban: light skin tone\n1F473 - 1F3FC 200D 2642 FE0F \; fully-qualified # 👳🏼‍ - ♂️ E4.0 man wearing turban: medium-light skin tone\n1F473 1F3FC 200D 2 - 642 \; minimally-qualified # 👳🏼‍♂ E4.0 man - wearing turban: medium-light skin tone\n1F473 1F3FD 200D 2642 FE0F - \; fully-qualified # 👳🏽‍♂️ E4.0 man wearing turba - n: medium skin tone\n1F473 1F3FD 200D 2642 \; minimal - ly-qualified # 👳🏽‍♂ E4.0 man wearing turban: medium skin tone\n1 - F473 1F3FE 200D 2642 FE0F \; fully-qualified # 👳 - 🏾‍♂️ E4.0 man wearing turban: medium-dark skin tone\n1F473 1F3FE - 200D 2642 \; minimally-qualified # 👳🏾‍♂ E4. - 0 man wearing turban: medium-dark skin tone\n1F473 1F3FF 200D 2642 FE0F - \; fully-qualified # 👳🏿‍♂️ E4.0 man wearing - turban: dark skin tone\n1F473 1F3FF 200D 2642 \; mini - mally-qualified # 👳🏿‍♂ E4.0 man wearing turban: dark skin tone\n - 1F473 200D 2640 FE0F \; fully-qualified # 👳 - ‍♀️ E4.0 woman wearing turban\n1F473 200D 2640 - \; minimally-qualified # 👳‍♀ E4.0 woman wearing turban\n1F473 - 1F3FB 200D 2640 FE0F \; fully-qualified # 👳🏻‍ - ♀️ E4.0 woman wearing turban: light skin tone\n1F473 1F3FB 200D 2640 - \; minimally-qualified # 👳🏻‍♀ E4.0 woman wea - ring turban: light skin tone\n1F473 1F3FC 200D 2640 FE0F \ - ; fully-qualified # 👳🏼‍♀️ E4.0 woman wearing turban: mediu - m-light skin tone\n1F473 1F3FC 200D 2640 \; minimally - -qualified # 👳🏼‍♀ E4.0 woman wearing turban: medium-light skin t - one\n1F473 1F3FD 200D 2640 FE0F \; fully-qualified # - ��🏽‍♀️ E4.0 woman wearing turban: medium skin tone\n1F473 1F3 - FD 200D 2640 \; minimally-qualified # 👳🏽‍♀ - E4.0 woman wearing turban: medium skin tone\n1F473 1F3FE 200D 2640 FE0F - \; fully-qualified # 👳🏾‍♀️ E4.0 woman wearin - g turban: medium-dark skin tone\n1F473 1F3FE 200D 2640 - \; minimally-qualified # 👳🏾‍♀ E4.0 woman wearing turban: mediu - m-dark skin tone\n1F473 1F3FF 200D 2640 FE0F \; fully-qual - ified # 👳🏿‍♀️ E4.0 woman wearing turban: dark skin tone\n1 - F473 1F3FF 200D 2640 \; minimally-qualified # 👳 - 🏿‍♀ E4.0 woman wearing turban: dark skin tone\n1F472 - \; fully-qualified # 👲 E2.0 man with skullca - p\n1F472 1F3FB \; fully-qualified # - 👲🏻 E2.0 man with skullcap: light skin tone\n1F472 1F3FC - \; fully-qualified # 👲🏼 E2.0 man with skullca - p: medium-light skin tone\n1F472 1F3FD \; f - ully-qualified # 👲🏽 E2.0 man with skullcap: medium skin tone\n1F - 472 1F3FE \; fully-qualified # 👲🏾 - E2.0 man with skullcap: medium-dark skin tone\n1F472 1F3FF - \; fully-qualified # 👲🏿 E2.0 man with skullcap: - dark skin tone\n1F9D5 \; fully-quali - fied # 🧕 E5.0 woman with headscarf\n1F9D5 1F3FB - \; fully-qualified # 🧕🏻 E5.0 woman with headscarf: l - ight skin tone\n1F9D5 1F3FC \; fully-qualif - ied # 🧕🏼 E5.0 woman with headscarf: medium-light skin tone\n1F9D - 5 1F3FD \; fully-qualified # 🧕🏽 E - 5.0 woman with headscarf: medium skin tone\n1F9D5 1F3FE - \; fully-qualified # 🧕🏾 E5.0 woman with headscarf: - medium-dark skin tone\n1F9D5 1F3FF \; fully - -qualified # 🧕🏿 E5.0 woman with headscarf: dark skin tone\n1F935 - \; fully-qualified # 🤵 E4.0 m - an in tuxedo\n1F935 1F3FB \; fully-qualifie - d # 🤵🏻 E4.0 man in tuxedo: light skin tone\n1F935 1F3FC - \; fully-qualified # 🤵🏼 E4.0 man in tuxed - o: medium-light skin tone\n1F935 1F3FD \; f - ully-qualified # 🤵🏽 E4.0 man in tuxedo: medium skin tone\n1F935 - 1F3FE \; fully-qualified # 🤵🏾 E4. - 0 man in tuxedo: medium-dark skin tone\n1F935 1F3FF - \; fully-qualified # 🤵🏿 E4.0 man in tuxedo: dark skin t - one\n1F470 \; fully-qualified # - 👰 E2.0 bride with veil\n1F470 1F3FB \; f - ully-qualified # ��🏻 E2.0 bride with veil: light skin tone\n1F4 - 70 1F3FC \; fully-qualified # 👰🏼 - E2.0 bride with veil: medium-light skin tone\n1F470 1F3FD - \; fully-qualified # 👰🏽 E2.0 bride with veil: med - ium skin tone\n1F470 1F3FE \; fully-qualifi - ed # 👰🏾 E2.0 bride with veil: medium-dark skin tone\n1F470 1F3FF - \; fully-qualified # 👰🏿 E2.0 bri - de with veil: dark skin tone\n1F930 \ - ; fully-qualified # 🤰 E4.0 pregnant woman\n1F930 1F3FB - \; fully-qualified # 🤰🏻 E4.0 pregnant woman: - light skin tone\n1F930 1F3FC \; fully-quali - fied # 🤰🏼 E4.0 pregnant woman: medium-light skin tone\n1F930 1F3 - FD \; fully-qualified # 🤰🏽 E4.0 p - regnant woman: medium skin tone\n1F930 1F3FE - \; fully-qualified # 🤰🏾 E4.0 pregnant woman: medium-dark skin - tone\n1F930 1F3FF \; fully-qualified # - 🤰�� E4.0 pregnant woman: dark skin tone\n1F931 - \; fully-qualified # 🤱 E5.0 breast-feeding\n1F931 - 1F3FB \; fully-qualified # 🤱🏻 E5. - 0 breast-feeding: light skin tone\n1F931 1F3FC - \; fully-qualified # 🤱🏼 E5.0 breast-feeding: medium-light sk - in tone\n1F931 1F3FD \; fully-qualified - # 🤱🏽 E5.0 breast-feeding: medium skin tone\n1F931 1F3FE - \; fully-qualified # 🤱🏾 E5.0 breast-feeding: - medium-dark skin tone\n1F931 1F3FF \; full - y-qualified # 🤱🏿 E5.0 breast-feeding: dark skin tone\n\n# subgro - up: person-fantasy\n1F47C \; fully-qu - alified # 👼 E2.0 baby angel\n1F47C 1F3FB - \; fully-qualified # 👼🏻 E2.0 baby angel: light skin tone\n1 - F47C 1F3FC \; fully-qualified # 👼 - 🏼 E2.0 baby angel: medium-light skin tone\n1F47C 1F3FD - \; fully-qualified # 👼🏽 E2.0 baby angel: medium s - kin tone\n1F47C 1F3FE \; fully-qualified - # 👼🏾 E2.0 baby angel: medium-dark skin tone\n1F47C 1F3FF - \; fully-qualified # 👼🏿 E2.0 baby angel: d - ark skin tone\n1F385 \; fully-qualifi - ed # 🎅 E2.0 Santa Claus\n1F385 1F3FB - \; fully-qualified # 🎅🏻 E2.0 Santa Claus: light skin tone\n1F38 - 5 1F3FC \; fully-qualified # 🎅🏼 E - 2.0 Santa Claus: medium-light skin tone\n1F385 1F3FD - \; fully-qualified # 🎅🏽 E2.0 Santa Claus: medium skin - tone\n1F385 1F3FE \; fully-qualified # - 🎅🏾 E2.0 Santa Claus: medium-dark skin tone\n1F385 1F3FF - \; fully-qualified # 🎅🏿 E2.0 Santa Claus: dar - k skin tone\n1F936 \; fully-qualified - # 🤶 E4.0 Mrs. Claus\n1F936 1F3FB \; - fully-qualified # 🤶🏻 E4.0 Mrs. Claus: light skin tone\n1F936 1F - 3FC \; fully-qualified # 🤶🏼 E4.0 - Mrs. Claus: medium-light skin tone\n1F936 1F3FD - \; fully-qualified # 🤶🏽 E4.0 Mrs. Claus: medium skin tone\n - 1F936 1F3FE \; fully-qualified # 🤶 - 🏾 E4.0 Mrs. Claus: medium-dark skin tone\n1F936 1F3FF - \; fully-qualified # 🤶🏿 E4.0 Mrs. Claus: dark skin - tone\n1F9B8 \; fully-qualified # - 🦸 E11.0 superhero\n1F9B8 1F3FB \; fully - -qualified # 🦸🏻 E11.0 superhero: light skin tone\n1F9B8 1F3FC - \; fully-qualified # 🦸🏼 E11.0 super - hero: medium-light skin tone\n1F9B8 1F3FD \ - ; fully-qualified # 🦸🏽 E11.0 superhero: medium skin tone\n1F9B8 - 1F3FE \; fully-qualified # 🦸🏾 E11 - .0 superhero: medium-dark skin tone\n1F9B8 1F3FF - \; fully-qualified # 🦸🏿 E11.0 superhero: dark skin tone\n1 - F9B8 200D 2642 FE0F \; fully-qualified # 🦸‍ - ♂️ E11.0 man superhero\n1F9B8 200D 2642 \; - minimally-qualified # 🦸‍♂ E11.0 man superhero\n1F9B8 1F3FB 200D 264 - 2 FE0F \; fully-qualified # 🦸🏻‍♂️ E11.0 ma - n superhero: light skin tone\n1F9B8 1F3FB 200D 2642 \ - ; minimally-qualified # 🦸🏻‍♂ E11.0 man superhero: light skin ton - e\n1F9B8 1F3FC 200D 2642 FE0F \; fully-qualified # - 🦸🏼‍♂️ E11.0 man superhero: medium-light skin tone\n1F9B8 1F3FC - 200D 2642 \; minimally-qualified # 🦸🏼‍♂ E1 - 1.0 man superhero: medium-light skin tone\n1F9B8 1F3FD 200D 2642 FE0F - \; fully-qualified # 🦸🏽‍♂️ E11.0 man superhero - : medium skin tone\n1F9B8 1F3FD 200D 2642 \; minimall - y-qualified # 🦸🏽‍♂ E11.0 man superhero: medium skin tone\n1F9B8 - 1F3FE 200D 2642 FE0F \; fully-qualified # 🦸🏾‍ - ♂️ E11.0 man superhero: medium-dark skin tone\n1F9B8 1F3FE 200D 2642 - \; minimally-qualified # 🦸🏾‍♂ E11.0 man supe - rhero: medium-dark skin tone\n1F9B8 1F3FF 200D 2642 FE0F \ - ; fully-qualified # 🦸🏿‍♂️ E11.0 man superhero: dark skin t - one\n1F9B8 1F3FF 200D 2642 \; minimally-qualified # - 🦸🏿‍♂ E11.0 man superhero: dark skin tone\n1F9B8 200D 2640 FE0F - \; fully-qualified # 🦸‍♀️ E11.0 woman su - perhero\n1F9B8 200D 2640 \; minimally-qualified - # 🦸‍♀ E11.0 woman superhero\n1F9B8 1F3FB 200D 2640 FE0F - \; fully-qualified # 🦸🏻‍♀️ E11.0 woman superhero: li - ght skin tone\n1F9B8 1F3FB 200D 2640 \; minimally-qua - lified # 🦸🏻‍♀ E11.0 woman superhero: light skin tone\n1F9B8 1F3F - C 200D 2640 FE0F \; fully-qualified # 🦸🏼‍♀ - ️ E11.0 woman superhero: medium-light skin tone\n1F9B8 1F3FC 200D 2640 - \; minimally-qualified # 🦸🏼‍♀ E11.0 woman su - perhero: medium-light skin tone\n1F9B8 1F3FD 200D 2640 FE0F - \; fully-qualified # 🦸🏽‍♀️ E11.0 woman superhero: medium - skin tone\n1F9B8 1F3FD 200D 2640 \; minimally-qualif - ied # 🦸🏽‍♀ E11.0 woman superhero: medium skin tone\n1F9B8 1F3FE - 200D 2640 FE0F \; fully-qualified # 🦸🏾‍♀️ - E11.0 woman superhero: medium-dark skin tone\n1F9B8 1F3FE 200D 2640 - \; minimally-qualified # 🦸🏾‍♀ E11.0 woman superhe - ro: medium-dark skin tone\n1F9B8 1F3FF 200D 2640 FE0F \; f - ully-qualified # 🦸🏿‍♀️ E11.0 woman superhero: dark skin to - ne\n1F9B8 1F3FF 200D 2640 \; minimally-qualified # - 🦸🏿‍♀ E11.0 woman superhero: dark skin tone\n1F9B9 - \; fully-qualified # 🦹 E11.0 supervillain\n1 - F9B9 1F3FB \; fully-qualified # 🦹 - 🏻 E11.0 supervillain: light skin tone\n1F9B9 1F3FC - \; fully-qualified # 🦹🏼 E11.0 supervillain: medium-li - ght skin tone\n1F9B9 1F3FD \; fully-qualifi - ed # 🦹🏽 E11.0 supervillain: medium skin tone\n1F9B9 1F3FE - \; fully-qualified # 🦹🏾 E11.0 supervill - ain: medium-dark skin tone\n1F9B9 1F3FF \; - fully-qualified # 🦹🏿 E11.0 supervillain: dark skin tone\n1F9B9 2 - 00D 2642 FE0F \; fully-qualified # 🦹‍♂️ - E11.0 man supervillain\n1F9B9 200D 2642 \; min - imally-qualified # 🦹‍♂ E11.0 man supervillain\n1F9B9 1F3FB 200D 264 - 2 FE0F \; fully-qualified # 🦹🏻‍♂️ E11.0 ma - n supervillain: light skin tone\n1F9B9 1F3FB 200D 2642 - \; minimally-qualified # 🦹🏻‍♂ E11.0 man supervillain: light sk - in tone\n1F9B9 1F3FC 200D 2642 FE0F \; fully-qualified - # 🦹🏼‍♂️ E11.0 man supervillain: medium-light skin tone\n1F9B9 - 1F3FC 200D 2642 \; minimally-qualified # 🦹🏼‍ - ♂ E11.0 man supervillain: medium-light skin tone\n1F9B9 1F3FD 200D 2642 - FE0F \; fully-qualified # 🦹🏽‍♂️ E11.0 man - supervillain: medium skin tone\n1F9B9 1F3FD 200D 2642 - \; minimally-qualified # 🦹🏽‍♂ E11.0 man supervillain: medium sk - in tone\n1F9B9 1F3FE 200D 2642 FE0F \; fully-qualified - # 🦹🏾‍♂️ E11.0 man supervillain: medium-dark skin tone\n1F9B9 - 1F3FE 200D 2642 \; minimally-qualified # 🦹🏾‍ - ♂ E11.0 man supervillain: medium-dark skin tone\n1F9B9 1F3FF 200D 2642 F - E0F \; fully-qualified # 🦹🏿‍♂️ E11.0 man s - upervillain: dark skin tone\n1F9B9 1F3FF 200D 2642 \; - minimally-qualified # 🦹🏿‍♂ E11.0 man supervillain: dark skin to - ne\n1F9B9 200D 2640 FE0F \; fully-qualified # - 🦹‍♀️ E11.0 woman supervillain\n1F9B9 200D 2640 - \; minimally-qualified # 🦹‍♀ E11.0 woman supervillain\n1F9 - B9 1F3FB 200D 2640 FE0F \; fully-qualified # 🦹🏻 - ‍♀️ E11.0 woman supervillain: light skin tone\n1F9B9 1F3FB 200D 2640 - \; minimally-qualified # 🦹🏻‍♀ E11.0 woman - supervillain: light skin tone\n1F9B9 1F3FC 200D 2640 FE0F - \; fully-qualified # 🦹��‍♀️ E11.0 woman supervillain: med - ium-light skin tone\n1F9B9 1F3FC 200D 2640 \; minimal - ly-qualified # 🦹🏼‍♀ E11.0 woman supervillain: medium-light skin - tone\n1F9B9 1F3FD 200D 2640 FE0F \; fully-qualified # - 🦹🏽‍♀️ E11.0 woman supervillain: medium skin tone\n1F9B9 1F3FD - 200D 2640 \; minimally-qualified # 🦹🏽‍♀ E11 - .0 woman supervillain: medium skin tone\n1F9B9 1F3FE 200D 2640 FE0F - \; fully-qualified # 🦹🏾‍♀️ E11.0 woman supervill - ain: medium-dark skin tone\n1F9B9 1F3FE 200D 2640 \; - minimally-qualified # 🦹🏾‍♀ E11.0 woman supervillain: medium-dark - skin tone\n1F9B9 1F3FF 200D 2640 FE0F \; fully-qualified - # 🦹🏿‍♀️ E11.0 woman supervillain: dark skin tone\n1F9B9 1F - 3FF 200D 2640 \; minimally-qualified # 🦹🏿‍♀ - E11.0 woman supervillain: dark skin tone\n1F9D9 - \; fully-qualified # 🧙 E5.0 mage\n1F9D9 1F3FB - \; fully-qualified # 🧙🏻 E5.0 mage: light s - kin tone\n1F9D9 1F3FC \; fully-qualified - # 🧙🏼 E5.0 mage: medium-light skin tone\n1F9D9 1F3FD - \; fully-qualified # 🧙🏽 E5.0 mage: medium skin - tone\n1F9D9 1F3FE \; fully-qualified # - 🧙🏾 E5.0 mage: medium-dark skin tone\n1F9D9 1F3FF - \; fully-qualified # 🧙🏿 E5.0 mage: dark skin tone\n1 - F9D9 200D 2642 FE0F \; fully-qualified # 🧙‍ - ♂️ E5.0 man mage\n1F9D9 200D 2642 \; minima - lly-qualified # 🧙‍♂ E5.0 man mage\n1F9D9 1F3FB 200D 2642 FE0F - \; fully-qualified # 🧙🏻‍♂️ E5.0 man mage: light - skin tone\n1F9D9 1F3FB 200D 2642 \; minimally-qualif - ied # 🧙🏻‍♂ E5.0 man mage: light skin tone\n1F9D9 1F3FC 200D 2642 - FE0F \; fully-qualified # 🧙🏼‍♂️ E5.0 man - mage: medium-light skin tone\n1F9D9 1F3FC 200D 2642 \ - ; minimally-qualified # 🧙🏼‍♂ E5.0 man mage: medium-light skin to - ne\n1F9D9 1F3FD 200D 2642 FE0F \; fully-qualified # - 🧙🏽‍♂️ E5.0 man mage: medium skin tone\n1F9D9 1F3FD 200D 2642 - \; minimally-qualified # 🧙🏽‍♂ E5.0 man mage: - medium skin tone\n1F9D9 1F3FE 200D 2642 FE0F \; fully-qua - lified # 🧙🏾‍♂️ E5.0 man mage: medium-dark skin tone\n1F9D9 - 1F3FE 200D 2642 \; minimally-qualified # 🧙🏾‍ - ♂ E5.0 man mage: medium-dark skin tone\n1F9D9 1F3FF 200D 2642 FE0F - \; fully-qualified # 🧙🏿‍♂️ E5.0 man mage: dark - skin tone\n1F9D9 1F3FF 200D 2642 \; minimally-qualifi - ed # 🧙🏿‍♂ E5.0 man mage: dark skin tone\n1F9D9 200D 2640 FE0F - \; fully-qualified # ��‍♀️ E5.0 woman ma - ge\n1F9D9 200D 2640 \; minimally-qualified # - 🧙‍♀ E5.0 woman mage\n1F9D9 1F3FB 200D 2640 FE0F \; - fully-qualified # 🧙🏻‍♀️ E5.0 woman mage: light skin tone\n - 1F9D9 1F3FB 200D 2640 \; minimally-qualified # 🧙 - 🏻‍♀ E5.0 woman mage: light skin tone\n1F9D9 1F3FC 200D 2640 FE0F - \; fully-qualified # 🧙🏼‍♀️ E5.0 woman mage: - medium-light skin tone\n1F9D9 1F3FC 200D 2640 \; mini - mally-qualified # 🧙🏼‍♀ E5.0 woman mage: medium-light skin tone\n - 1F9D9 1F3FD 200D 2640 FE0F \; fully-qualified # 🧙 - 🏽‍♀️ E5.0 woman mage: medium skin tone\n1F9D9 1F3FD 200D 2640 - \; minimally-qualified # 🧙🏽‍♀ E5.0 woman mage: - medium skin tone\n1F9D9 1F3FE 200D 2640 FE0F \; fully-qua - lified # 🧙🏾‍♀️ E5.0 woman mage: medium-dark skin tone\n1F9 - D9 1F3FE 200D 2640 \; minimally-qualified # 🧙🏾 - ‍♀ E5.0 woman mage: medium-dark skin tone\n1F9D9 1F3FF 200D 2640 FE0F - \; fully-qualified # 🧙🏿‍♀️ E5.0 woman mage - : dark skin tone\n1F9D9 1F3FF 200D 2640 \; minimally- - qualified # 🧙🏿‍♀ E5.0 woman mage: dark skin tone\n1F9DA - \; fully-qualified # 🧚 E5.0 fairy\n1F9 - DA 1F3FB \; fully-qualified # 🧚🏻 - E5.0 fairy: light skin tone\n1F9DA 1F3FC \; - fully-qualified # 🧚🏼 E5.0 fairy: medium-light skin tone\n1F9DA - 1F3FD \; fully-qualified # 🧚🏽 E5. - 0 fairy: medium skin tone\n1F9DA 1F3FE \; f - ully-qualified # 🧚🏾 E5.0 fairy: medium-dark skin tone\n1F9DA 1F3 - FF \; fully-qualified # 🧚🏿 E5.0 f - airy: dark skin tone\n1F9DA 200D 2642 FE0F \; fully- - qualified # 🧚‍♂️ E5.0 man fairy\n1F9DA 200D 2642 - \; minimally-qualified # 🧚‍♂ E5.0 man fairy\n1F9DA 1 - F3FB 200D 2642 FE0F \; fully-qualified # 🧚🏻‍ - ♂️ E5.0 man fairy: light skin tone\n1F9DA 1F3FB 200D 2642 - \; minimally-qualified # 🧚🏻‍♂ E5.0 man fairy: light ski - n tone\n1F9DA 1F3FC 200D 2642 FE0F \; fully-qualified - # 🧚🏼‍♂️ E5.0 man fairy: medium-light skin tone\n1F9DA 1F3FC 20 - 0D 2642 \; minimally-qualified # 🧚🏼‍♂ E5.0 - man fairy: medium-light skin tone\n1F9DA 1F3FD 200D 2642 FE0F - \; fully-qualified # 🧚🏽‍♂️ E5.0 man fairy: medium skin - tone\n1F9DA 1F3FD 200D 2642 \; minimally-qualified # - 🧚🏽‍♂ E5.0 man fairy: medium skin tone\n1F9DA 1F3FE 200D 2642 FE - 0F \; fully-qualified # 🧚🏾‍♂️ E5.0 man fai - ry: medium-dark skin tone\n1F9DA 1F3FE 200D 2642 \; m - inimally-qualified # 🧚🏾‍♂ E5.0 man fairy: medium-dark skin tone\ - n1F9DA 1F3FF 200D 2642 FE0F \; fully-qualified # 🧚 - 🏿‍♂️ E5.0 man fairy: dark skin tone\n1F9DA 1F3FF 200D 2642 - \; minimally-qualified # 🧚🏿‍♂ E5.0 man fairy: dar - k skin tone\n1F9DA 200D 2640 FE0F \; fully-qualified - # 🧚‍♀️ E5.0 woman fairy\n1F9DA 200D 2640 - \; minimally-qualified # 🧚‍♀ E5.0 woman fairy\n1F9DA 1F3FB - 200D 2640 FE0F \; fully-qualified # 🧚🏻‍♀️ - E5.0 woman fairy: light skin tone\n1F9DA 1F3FB 200D 2640 - \; minimally-qualified # 🧚🏻‍♀ E5.0 woman fairy: light skin t - one\n1F9DA 1F3FC 200D 2640 FE0F \; fully-qualified # - 🧚🏼‍♀️ E5.0 woman fairy: medium-light skin tone\n1F9DA 1F3FC 20 - 0D 2640 \; minimally-qualified # 🧚🏼‍♀ E5.0 - woman fairy: medium-light skin tone\n1F9DA 1F3FD 200D 2640 FE0F - \; fully-qualified # 🧚🏽‍♀️ E5.0 woman fairy: medium - skin tone\n1F9DA 1F3FD 200D 2640 \; minimally-qualifi - ed # 🧚🏽‍♀ E5.0 woman fairy: medium skin tone\n1F9DA 1F3FE 200D 2 - 640 FE0F \; fully-qualified # 🧚🏾‍♀️ E5.0 w - oman fairy: medium-dark skin tone\n1F9DA 1F3FE 200D 2640 - \; minimally-qualified # 🧚🏾‍♀ E5.0 woman fairy: medium-dark - skin tone\n1F9DA 1F3FF 200D 2640 FE0F \; fully-qualified - # 🧚🏿‍♀️ E5.0 woman fairy: dark skin tone\n1F9DA 1F3FF 200D - 2640 \; minimally-qualified # 🧚🏿‍♀ E5.0 wom - an fairy: dark skin tone\n1F9DB \; fu - lly-qualified # 🧛 E5.0 vampire\n1F9DB 1F3FB - \; fully-qualified # 🧛🏻 E5.0 vampire: light skin tone\n1 - F9DB 1F3FC \; fully-qualified # 🧛 - 🏼 E5.0 vampire: medium-light skin tone\n1F9DB 1F3FD - \; fully-qualified # 🧛🏽 E5.0 vampire: medium skin to - ne\n1F9DB 1F3FE \; fully-qualified # - 🧛🏾 E5.0 vampire: medium-dark skin tone\n1F9DB 1F3FF - \; fully-qualified # 🧛🏿 E5.0 vampire: dark skin t - one\n1F9DB 200D 2642 FE0F \; fully-qualified # - 🧛‍♂️ E5.0 man vampire\n1F9DB 200D 2642 - \; minimally-qualified # 🧛‍♂ E5.0 man vampire\n1F9DB 1F3FB 200D 26 - 42 FE0F \; fully-qualified # 🧛🏻‍♂️ E5.0 ma - n vampire: light skin tone\n1F9DB 1F3FB 200D 2642 \; - minimally-qualified # 🧛🏻‍♂ E5.0 man vampire: light skin tone\n1F - 9DB 1F3FC 200D 2642 FE0F \; fully-qualified # 🧛🏼 - ‍♂️ E5.0 man vampire: medium-light skin tone\n1F9DB 1F3FC 200D 2642 - \; minimally-qualified # 🧛🏼‍♂ E5.0 man vamp - ire: medium-light skin tone\n1F9DB 1F3FD 200D 2642 FE0F \; - fully-qualified # 🧛🏽‍♂️ E5.0 man vampire: medium skin ton - e\n1F9DB 1F3FD 200D 2642 \; minimally-qualified # - 🧛🏽‍♂ E5.0 man vampire: medium skin tone\n1F9DB 1F3FE 200D 2642 F - E0F \; fully-qualified # 🧛🏾‍♂️ E5.0 man va - mpire: medium-dark skin tone\n1F9DB 1F3FE 200D 2642 \ - ; minimally-qualified # 🧛🏾‍♂ E5.0 man vampire: medium-dark skin - tone\n1F9DB 1F3FF 200D 2642 FE0F \; fully-qualified # - ��🏿‍♂️ E5.0 man vampire: dark skin tone\n1F9DB 1F3FF 200D 264 - 2 \; minimally-qualified # 🧛🏿‍♂ E5.0 man va - mpire: dark skin tone\n1F9DB 200D 2640 FE0F \; fully - -qualified # 🧛‍♀️ E5.0 woman vampire\n1F9DB 200D 2640 - \; minimally-qualified # 🧛‍♀ E5.0 woman vampire - \n1F9DB 1F3FB 200D 2640 FE0F \; fully-qualified # 🧛 - 🏻‍♀️ E5.0 woman vampire: light skin tone\n1F9DB 1F3FB 200D 2640 - \; minimally-qualified # 🧛🏻‍♀ E5.0 woman vam - pire: light skin tone\n1F9DB 1F3FC 200D 2640 FE0F \; fully - -qualified # 🧛🏼‍♀️ E5.0 woman vampire: medium-light skin t - one\n1F9DB 1F3FC 200D 2640 \; minimally-qualified # - 🧛🏼‍♀ E5.0 woman vampire: medium-light skin tone\n1F9DB 1F3FD 200 - D 2640 FE0F \; fully-qualified # 🧛🏽‍♀️ E5. - 0 woman vampire: medium skin tone\n1F9DB 1F3FD 200D 2640 - \; minimally-qualified # 🧛🏽‍♀ E5.0 woman vampire: medium ski - n tone\n1F9DB 1F3FE 200D 2640 FE0F \; fully-qualified - # 🧛🏾‍♀️ E5.0 woman vampire: medium-dark skin tone\n1F9DB 1F3FE - 200D 2640 \; minimally-qualified # 🧛🏾‍♀ E5 - .0 woman vampire: medium-dark skin tone\n1F9DB 1F3FF 200D 2640 FE0F - \; fully-qualified # 🧛🏿‍♀️ E5.0 woman vampire: d - ark skin tone\n1F9DB 1F3FF 200D 2640 \; minimally-qua - lified # 🧛🏿‍♀ E5.0 woman vampire: dark skin tone\n1F9DC - \; fully-qualified # 🧜 E5.0 merperson\ - n1F9DC 1F3FB \; fully-qualified # 🧜 - 🏻 E5.0 merperson: light skin tone\n1F9DC 1F3FC - \; fully-qualified # 🧜🏼 E5.0 merperson: medium-light skin - tone\n1F9DC 1F3FD \; fully-qualified # - 🧜🏽 E5.0 merperson: medium skin tone\n1F9DC 1F3FE - \; fully-qualified # 🧜🏾 E5.0 merperson: medium-dark - skin tone\n1F9DC 1F3FF \; fully-qualified - # 🧜🏿 E5.0 merperson: dark skin tone\n1F9DC 200D 2642 FE0F - \; fully-qualified # 🧜‍♂️ E5.0 merman\n1F9DC - 200D 2642 \; minimally-qualified # 🧜‍♂ E - 5.0 merman\n1F9DC 1F3FB 200D 2642 FE0F \; fully-qualified - # 🧜🏻‍♂️ E5.0 merman: light skin tone\n1F9DC 1F3FB 200D 264 - 2 \; minimally-qualified # 🧜🏻‍♂ E5.0 merman - : light skin tone\n1F9DC 1F3FC 200D 2642 FE0F \; fully-qua - lified # 🧜🏼‍♂️ E5.0 merman: medium-light skin tone\n1F9DC - 1F3FC 200D 2642 \; minimally-qualified # 🧜🏼‍ - ♂ E5.0 merman: medium-light skin tone\n1F9DC 1F3FD 200D 2642 FE0F - \; fully-qualified # 🧜🏽‍♂️ E5.0 merman: medium s - kin tone\n1F9DC 1F3FD 200D 2642 \; minimally-qualifie - d # 🧜🏽‍♂ E5.0 merman: medium skin tone\n1F9DC 1F3FE 200D 2642 FE - 0F \; fully-qualified # 🧜🏾‍♂️ E5.0 merman: - medium-dark skin tone\n1F9DC 1F3FE 200D 2642 \; mini - mally-qualified # 🧜🏾‍♂ E5.0 merman: medium-dark skin tone\n1F9DC - 1F3FF 200D 2642 FE0F \; fully-qualified # 🧜🏿‍ - ♂️ E5.0 merman: dark skin tone\n1F9DC 1F3FF 200D 2642 - \; minimally-qualified # 🧜🏿‍♂ E5.0 merman: dark skin tone\n - 1F9DC 200D 2640 FE0F \; fully-qualified # 🧜 - ‍♀️ E5.0 mermaid\n1F9DC 200D 2640 \; mini - mally-qualified # 🧜‍♀ E5.0 mermaid\n1F9DC 1F3FB 200D 2640 FE0F - \; fully-qualified # 🧜🏻‍♀️ E5.0 mermaid: light - skin tone\n1F9DC 1F3FB 200D 2640 \; minimally-qualif - ied # 🧜🏻‍♀ E5.0 mermaid: light skin tone\n1F9DC 1F3FC 200D 2640 - FE0F \; fully-qualified # 🧜🏼‍♀️ E5.0 merma - id: medium-light skin tone\n1F9DC 1F3FC 200D 2640 \; - minimally-qualified # 🧜🏼‍♀ E5.0 mermaid: medium-light skin tone\ - n1F9DC 1F3FD 200D 2640 FE0F \; fully-qualified # 🧜 - 🏽‍♀️ E5.0 mermaid: medium skin tone\n1F9DC 1F3FD 200D 2640 - \; minimally-qualified # 🧜🏽‍♀ E5.0 mermaid: mediu - m skin tone\n1F9DC 1F3FE 200D 2640 FE0F \; fully-qualified - # 🧜🏾‍♀️ E5.0 mermaid: medium-dark skin tone\n1F9DC 1F3FE - 200D 2640 \; minimally-qualified # 🧜🏾‍♀ E5. - 0 mermaid: medium-dark skin tone\n1F9DC 1F3FF 200D 2640 FE0F - \; fully-qualified # 🧜🏿‍♀️ E5.0 mermaid: dark skin tone - \n1F9DC 1F3FF 200D 2640 \; minimally-qualified # 🧜 - 🏿‍♀ E5.0 mermaid: dark skin tone\n1F9DD - \; fully-qualified # 🧝 E5.0 elf\n1F9DD 1F3FB - \; fully-qualified # 🧝🏻 E5.0 elf: light skin - tone\n1F9DD 1F3FC \; fully-qualified # - 🧝🏼 E5.0 elf: medium-light skin tone\n1F9DD 1F3FD - \; fully-qualified # 🧝🏽 E5.0 elf: medium skin tone\n - 1F9DD 1F3FE \; fully-qualified # 🧝 - 🏾 E5.0 elf: medium-dark skin tone\n1F9DD 1F3FF - \; fully-qualified # 🧝🏿 E5.0 elf: dark skin tone\n1F9DD 2 - 00D 2642 FE0F \; fully-qualified # 🧝‍♂️ - E5.0 man elf\n1F9DD 200D 2642 \; minimally-qua - lified # 🧝‍♂ E5.0 man elf\n1F9DD 1F3FB 200D 2642 FE0F - \; fully-qualified # 🧝🏻‍♂️ E5.0 man elf: light skin ton - e\n1F9DD 1F3FB 200D 2642 \; minimally-qualified # - 🧝🏻‍♂ E5.0 man elf: light skin tone\n1F9DD 1F3FC 200D 2642 FE0F - \; fully-qualified # 🧝🏼‍♂️ E5.0 man elf: me - dium-light skin tone\n1F9DD 1F3FC 200D 2642 \; minima - lly-qualified # 🧝🏼‍♂ E5.0 man elf: medium-light skin tone\n1F9DD - 1F3FD 200D 2642 FE0F \; fully-qualified # 🧝🏽‍ - ♂️ E5.0 man elf: medium skin tone\n1F9DD 1F3FD 200D 2642 - \; minimally-qualified # 🧝🏽‍♂ E5.0 man elf: medium skin - tone\n1F9DD 1F3FE 200D 2642 FE0F \; fully-qualified # - 🧝🏾‍♂️ E5.0 man elf: medium-dark skin tone\n1F9DD 1F3FE 200D 26 - 42 \; minimally-qualified # 🧝🏾‍♂ E5.0 man e - lf: medium-dark skin tone\n1F9DD 1F3FF 200D 2642 FE0F \; f - ully-qualified # 🧝🏿‍♂️ E5.0 man elf: dark skin tone\n1F9DD - 1F3FF 200D 2642 \; minimally-qualified # 🧝🏿‍ - ♂ E5.0 man elf: dark skin tone\n1F9DD 200D 2640 FE0F - \; fully-qualified # 🧝‍♀️ E5.0 woman elf\n1F9DD 200D 2640 - \; minimally-qualified # 🧝‍♀ E5.0 woman - elf\n1F9DD 1F3FB 200D 2640 FE0F \; fully-qualified # - 🧝🏻‍♀️ E5.0 woman elf: light skin tone\n1F9DD 1F3FB 200D 2640 - \; minimally-qualified # 🧝🏻‍♀ E5.0 woman elf - : light skin tone\n1F9DD 1F3FC 200D 2640 FE0F \; fully-qua - lified # 🧝🏼‍♀️ E5.0 woman elf: medium-light skin tone\n1F9 - DD 1F3FC 200D 2640 \; minimally-qualified # 🧝🏼 - ‍♀ E5.0 woman elf: medium-light skin tone\n1F9DD 1F3FD 200D 2640 FE0F - \; fully-qualified # 🧝🏽‍♀️ E5.0 woman elf: - medium skin tone\n1F9DD 1F3FD 200D 2640 \; minimally - -qualified # 🧝🏽‍♀ E5.0 woman elf: medium skin tone\n1F9DD 1F3FE - 200D 2640 FE0F \; fully-qualified # 🧝🏾‍♀️ - E5.0 woman elf: medium-dark skin tone\n1F9DD 1F3FE 200D 2640 - \; minimally-qualified # 🧝🏾‍♀ E5.0 woman elf: medium-dar - k skin tone\n1F9DD 1F3FF 200D 2640 FE0F \; fully-qualified - # 🧝🏿‍♀️ E5.0 woman elf: dark skin tone\n1F9DD 1F3FF 200D - 2640 \; minimally-qualified # 🧝🏿‍♀ E5.0 wom - an elf: dark skin tone\n1F9DE \; full - y-qualified # 🧞 E5.0 genie\n1F9DE 200D 2642 FE0F - \; fully-qualified # 🧞‍♂️ E5.0 man genie\n1F9DE 200D 2642 - \; minimally-qualified # 🧞‍♂ E5.0 man g - enie\n1F9DE 200D 2640 FE0F \; fully-qualified # - 🧞‍♀️ E5.0 woman genie\n1F9DE 200D 2640 - \; minimally-qualified # 🧞‍♀ E5.0 woman genie\n1F9DF - \; fully-qualified # 🧟 E5.0 zombie\n1F9DF 2 - 00D 2642 FE0F \; fully-qualified # 🧟‍♂️ - E5.0 man zombie\n1F9DF 200D 2642 \; minimally- - qualified # 🧟‍♂ E5.0 man zombie\n1F9DF 200D 2640 FE0F - \; fully-qualified # 🧟‍♀️ E5.0 woman zombie\n1F9DF 2 - 00D 2640 \; minimally-qualified # 🧟‍♀ E5 - .0 woman zombie\n\n# subgroup: person-activity\n1F486 - \; fully-qualified # 💆 E2.0 person getting massage - \n1F486 1F3FB \; fully-qualified # 💆 - 🏻 E2.0 person getting massage: light skin tone\n1F486 1F3FC - \; fully-qualified # 💆🏼 E2.0 person getting - massage: medium-light skin tone\n1F486 1F3FD - \; fully-qualified # 💆🏽 E2.0 person getting massage: medium sk - in tone\n1F486 1F3FE \; fully-qualified - # 💆🏾 E2.0 person getting massage: medium-dark skin tone\n1F486 1F3F - F \; fully-qualified # 💆�� E2.0 - person getting massage: dark skin tone\n1F486 200D 2642 FE0F - \; fully-qualified # 💆‍♂️ E4.0 man getting massage\n - 1F486 200D 2642 \; minimally-qualified # 💆 - ‍♂ E4.0 man getting massage\n1F486 1F3FB 200D 2642 FE0F - \; fully-qualified # 💆🏻‍♂️ E4.0 man getting massage: lig - ht skin tone\n1F486 1F3FB 200D 2642 \; minimally-qual - ified # 💆🏻‍♂ E4.0 man getting massage: light skin tone\n1F486 1F - 3FC 200D 2642 FE0F \; fully-qualified # 💆🏼‍♂ - ️ E4.0 man getting massage: medium-light skin tone\n1F486 1F3FC 200D 264 - 2 \; minimally-qualified # 💆🏼‍♂ E4.0 man ge - tting massage: medium-light skin tone\n1F486 1F3FD 200D 2642 FE0F - \; fully-qualified # 💆🏽‍♂️ E4.0 man getting massag - e: medium skin tone\n1F486 1F3FD 200D 2642 \; minimal - ly-qualified # 💆🏽‍♂ E4.0 man getting massage: medium skin tone\n - 1F486 1F3FE 200D 2642 FE0F \; fully-qualified # 💆 - 🏾‍♂️ E4.0 man getting massage: medium-dark skin tone\n1F486 1F3FE - 200D 2642 \; minimally-qualified # 💆🏾‍♂ E4 - .0 man getting massage: medium-dark skin tone\n1F486 1F3FF 200D 2642 FE0F - \; fully-qualified # 💆🏿‍♂️ E4.0 man gettin - g massage: dark skin tone\n1F486 1F3FF 200D 2642 \; m - inimally-qualified # 💆🏿‍♂ E4.0 man getting massage: dark skin to - ne\n1F486 200D 2640 FE0F \; fully-qualified # - 💆‍♀️ E4.0 woman getting massage\n1F486 200D 2640 - \; minimally-qualified # 💆‍♀ E4.0 woman getting massage\ - n1F486 1F3FB 200D 2640 FE0F \; fully-qualified # 💆 - 🏻‍♀️ E4.0 woman getting massage: light skin tone\n1F486 1F3FB 200 - D 2640 \; minimally-qualified # 💆🏻‍♀ E4.0 w - oman getting massage: light skin tone\n1F486 1F3FC 200D 2640 FE0F - \; fully-qualified # 💆🏼‍♀️ E4.0 woman getting mass - age: medium-light skin tone\n1F486 1F3FC 200D 2640 \; - minimally-qualified # 💆��‍♀ E4.0 woman getting massage: medium - -light skin tone\n1F486 1F3FD 200D 2640 FE0F \; fully-qual - ified # 💆🏽‍♀️ E4.0 woman getting massage: medium skin tone - \n1F486 1F3FD 200D 2640 \; minimally-qualified # 💆 - 🏽‍♀ E4.0 woman getting massage: medium skin tone\n1F486 1F3FE 200D - 2640 FE0F \; fully-qualified # 💆🏾‍♀️ E4.0 - woman getting massage: medium-dark skin tone\n1F486 1F3FE 200D 2640 - \; minimally-qualified # 💆🏾‍♀ E4.0 woman getting - massage: medium-dark skin tone\n1F486 1F3FF 200D 2640 FE0F - \; fully-qualified # 💆🏿‍♀️ E4.0 woman getting massage: da - rk skin tone\n1F486 1F3FF 200D 2640 \; minimally-qual - ified # 💆🏿‍♀ E4.0 woman getting massage: dark skin tone\n1F487 - \; fully-qualified # 💇 E2.0 per - son getting haircut\n1F487 1F3FB \; fully-q - ualified # 💇🏻 E2.0 person getting haircut: light skin tone\n1F48 - 7 1F3FC \; fully-qualified # 💇🏼 E - 2.0 person getting haircut: medium-light skin tone\n1F487 1F3FD - \; fully-qualified # 💇🏽 E2.0 person getting - haircut: medium skin tone\n1F487 1F3FE \; - fully-qualified # 💇🏾 E2.0 person getting haircut: medium-dark sk - in tone\n1F487 1F3FF \; fully-qualified - # 💇🏿 E2.0 person getting haircut: dark skin tone\n1F487 200D 2642 F - E0F \; fully-qualified # 💇‍♂️ E4.0 man - getting haircut\n1F487 200D 2642 \; minimally-q - ualified # 💇‍♂ E4.0 man getting haircut\n1F487 1F3FB 200D 2642 FE0F - \; fully-qualified # 💇🏻‍♂️ E4.0 man getti - ng haircut: light skin tone\n1F487 1F3FB 200D 2642 \; - minimally-qualified # 💇🏻‍♂ E4.0 man getting haircut: light skin - tone\n1F487 1F3FC 200D 2642 FE0F \; fully-qualified # - 💇🏼‍♂️ E4.0 man getting haircut: medium-light skin tone\n1F487 - 1F3FC 200D 2642 \; minimally-qualified # 💇🏼‍ - ♂ E4.0 man getting haircut: medium-light skin tone\n1F487 1F3FD 200D 264 - 2 FE0F \; fully-qualified # 💇🏽‍♂️ E4.0 man - getting haircut: medium skin tone\n1F487 1F3FD 200D 2642 - \; minimally-qualified # 💇🏽‍♂ E4.0 man getting haircut: med - ium skin tone\n1F487 1F3FE 200D 2642 FE0F \; fully-qualifi - ed # 💇🏾‍♂️ E4.0 man getting haircut: medium-dark skin tone - \n1F487 1F3FE 200D 2642 \; minimally-qualified # 💇 - 🏾‍♂ E4.0 man getting haircut: medium-dark skin tone\n1F487 1F3FF 20 - 0D 2642 FE0F \; fully-qualified # 💇🏿‍♂️ E4 - .0 man getting haircut: dark skin tone\n1F487 1F3FF 200D 2642 - \; minimally-qualified # 💇🏿‍♂ E4.0 man getting haircut: - dark skin tone\n1F487 200D 2640 FE0F \; fully-quali - fied # 💇‍♀️ E4.0 woman getting haircut\n1F487 200D 2640 - \; minimally-qualified # 💇‍♀ E4.0 woman getti - ng haircut\n1F487 1F3FB 200D 2640 FE0F \; fully-qualified - # 💇🏻‍♀️ E4.0 woman getting haircut: light skin tone\n1F487 - 1F3FB 200D 2640 \; minimally-qualified # 💇🏻‍ - ♀ E4.0 woman getting haircut: light skin tone\n1F487 1F3FC 200D 2640 FE0 - F \; fully-qualified # 💇🏼‍♀️ E4.0 woman ge - tting haircut: medium-light skin tone\n1F487 1F3FC 200D 2640 - \; minimally-qualified # 💇🏼‍♀ E4.0 woman getting haircut - : medium-light skin tone\n1F487 1F3FD 200D 2640 FE0F \; fu - lly-qualified # 💇🏽‍♀️ E4.0 woman getting haircut: medium s - kin tone\n1F487 1F3FD 200D 2640 \; minimally-qualifie - d # 💇🏽‍♀ E4.0 woman getting haircut: medium skin tone\n1F487 1F3 - FE 200D 2640 FE0F \; fully-qualified # 💇🏾‍♀ - ️ E4.0 woman getting haircut: medium-dark skin tone\n1F487 1F3FE 200D 26 - 40 \; minimally-qualified # 💇🏾‍♀ E4.0 woman - getting haircut: medium-dark skin tone\n1F487 1F3FF 200D 2640 FE0F - \; fully-qualified # 💇🏿‍♀️ E4.0 woman getting ha - ircut: dark skin tone\n1F487 1F3FF 200D 2640 \; minim - ally-qualified # 💇🏿‍♀ E4.0 woman getting haircut: dark skin tone - \n1F6B6 \; fully-qualified # 🚶 - E2.0 person walking\n1F6B6 1F3FB \; fully- - qualified # 🚶🏻 E2.0 person walking: light skin tone\n1F6B6 1F3FC - \; fully-qualified # 🚶🏼 E2.0 per - son walking: medium-light skin tone\n1F6B6 1F3FD - \; fully-qualified # 🚶🏽 E2.0 person walking: medium skin t - one\n1F6B6 1F3FE \; fully-qualified # - 🚶🏾 E2.0 person walking: medium-dark skin tone\n1F6B6 1F3FF - \; fully-qualified # 🚶🏿 E2.0 person walkin - g: dark skin tone\n1F6B6 200D 2642 FE0F \; fully-qua - lified # 🚶‍♂️ E4.0 man walking\n1F6B6 200D 2642 - \; minimally-qualified # 🚶‍♂ E4.0 man walking\n1F6B6 - 1F3FB 200D 2642 FE0F \; fully-qualified # 🚶🏻‍ - ♂️ E4.0 man walking: light skin tone\n1F6B6 1F3FB 200D 2642 - \; minimally-qualified # 🚶🏻‍♂ E4.0 man walking: light - skin tone\n1F6B6 1F3FC 200D 2642 FE0F \; fully-qualified - # 🚶🏼‍♂️ E4.0 man walking: medium-light skin tone\n1F6B6 1F - 3FC 200D 2642 \; minimally-qualified # 🚶🏼‍♂ - E4.0 man walking: medium-light skin tone\n1F6B6 1F3FD 200D 2642 FE0F - \; fully-qualified # 🚶🏽‍♂️ E4.0 man walking: m - edium skin tone\n1F6B6 1F3FD 200D 2642 \; minimally-q - ualified # 🚶🏽‍♂ E4.0 man walking: medium skin tone\n1F6B6 1F3FE - 200D 2642 FE0F \; fully-qualified # 🚶🏾‍♂️ - E4.0 man walking: medium-dark skin tone\n1F6B6 1F3FE 200D 2642 - \; minimally-qualified # 🚶🏾‍♂ E4.0 man walking: medium - -dark skin tone\n1F6B6 1F3FF 200D 2642 FE0F \; fully-quali - fied # 🚶🏿‍♂️ E4.0 man walking: dark skin tone\n1F6B6 1F3FF - 200D 2642 \; minimally-qualified # 🚶🏿‍♂ E4 - .0 man walking: dark skin tone\n1F6B6 200D 2640 FE0F - \; fully-qualified # 🚶‍♀️ E4.0 woman walking\n1F6B6 200D 264 - 0 \; minimally-qualified # 🚶‍♀ E4.0 woma - n walking\n1F6B6 1F3FB 200D 2640 FE0F \; fully-qualified - # 🚶🏻‍♀️ E4.0 woman walking: light skin tone\n1F6B6 1F3FB 20 - 0D 2640 \; minimally-qualified # 🚶🏻‍♀ E4.0 - woman walking: light skin tone\n1F6B6 1F3FC 200D 2640 FE0F - \; fully-qualified # 🚶🏼‍♀️ E4.0 woman walking: medium-lig - ht skin tone\n1F6B6 1F3FC 200D 2640 \; minimally-qual - ified # 🚶🏼‍♀ E4.0 woman walking: medium-light skin tone\n1F6B6 1 - F3FD 200D 2640 FE0F \; fully-qualified # 🚶🏽‍ - ♀️ E4.0 woman walking: medium skin tone\n1F6B6 1F3FD 200D 2640 - \; minimally-qualified # 🚶🏽‍♀ E4.0 woman walking: - medium skin tone\n1F6B6 1F3FE 200D 2640 FE0F \; fully-qual - ified # 🚶🏾‍♀️ E4.0 woman walking: medium-dark skin tone\n1 - F6B6 1F3FE 200D 2640 \; minimally-qualified # 🚶 - 🏾‍♀ E4.0 woman walking: medium-dark skin tone\n1F6B6 1F3FF 200D 264 - 0 FE0F \; fully-qualified # 🚶🏿‍♀️ E4.0 wom - an walking: dark skin tone\n1F6B6 1F3FF 200D 2640 \; - minimally-qualified # 🚶🏿‍♀ E4.0 woman walking: dark skin tone\n1 - F9CD \; fully-qualified # 🧍 E1 - 2.1 person standing\n1F9CD 1F3FB \; fully-q - ualified # 🧍🏻 E12.1 person standing: light skin tone\n1F9CD 1F3F - C \; fully-qualified # 🧍🏼 E12.1 p - erson standing: medium-light skin tone\n1F9CD 1F3FD - \; fully-qualified # 🧍🏽 E12.1 person standing: medium s - kin tone\n1F9CD 1F3FE \; fully-qualified - # 🧍🏾 E12.1 person standing: medium-dark skin tone\n1F9CD 1F3FF - \; fully-qualified # 🧍🏿 E12.1 person - standing: dark skin tone\n1F9CD 200D 2642 FE0F \; f - ully-qualified # 🧍‍♂️ E12.1 man standing\n1F9CD 200D 2642 - \; minimally-qualified # 🧍‍♂ E12.1 man stan - ding\n1F9CD 1F3FB 200D 2642 FE0F \; fully-qualified # - 🧍🏻‍♂️ E12.1 man standing: light skin tone\n1F9CD 1F3FB 200D 26 - 42 \; minimally-qualified # 🧍🏻‍♂ E12.1 man - standing: light skin tone\n1F9CD 1F3FC 200D 2642 FE0F \; f - ully-qualified # 🧍🏼‍♂️ E12.1 man standing: medium-light sk - in tone\n1F9CD 1F3FC 200D 2642 \; minimally-qualified - # 🧍🏼‍♂ E12.1 man standing: medium-light skin tone\n1F9CD 1F3FD - 200D 2642 FE0F \; fully-qualified # 🧍🏽‍♂️ - E12.1 man standing: medium skin tone\n1F9CD 1F3FD 200D 2642 - \; minimally-qualified # 🧍🏽‍♂ E12.1 man standing: medium - skin tone\n1F9CD 1F3FE 200D 2642 FE0F \; fully-qualified - # 🧍🏾‍♂️ E12.1 man standing: medium-dark skin tone\n1F9CD 1F - 3FE 200D 2642 \; minimally-qualified # 🧍🏾‍♂ - E12.1 man standing: medium-dark skin tone\n1F9CD 1F3FF 200D 2642 FE0F - \; fully-qualified # 🧍🏿‍♂️ E12.1 man standing - : dark skin tone\n1F9CD 1F3FF 200D 2642 \; minimally- - qualified # 🧍🏿‍♂ E12.1 man standing: dark skin tone\n1F9CD 200D - 2640 FE0F \; fully-qualified # 🧍‍♀️ E12 - .1 woman standing\n1F9CD 200D 2640 \; minimally - -qualified # 🧍‍♀ E12.1 woman standing\n1F9CD 1F3FB 200D 2640 FE0F - \; fully-qualified # 🧍🏻‍♀️ E12.1 woman stan - ding: light skin tone\n1F9CD 1F3FB 200D 2640 \; minim - ally-qualified # 🧍🏻‍♀ E12.1 woman standing: light skin tone\n1F9 - CD 1F3FC 200D 2640 FE0F \; fully-qualified # 🧍🏼 - ‍♀️ E12.1 woman standing: medium-light skin tone\n1F9CD 1F3FC 200D 2 - 640 \; minimally-qualified # 🧍🏼‍♀ E12.1 wom - an standing: medium-light skin tone\n1F9CD 1F3FD 200D 2640 FE0F - \; fully-qualified # 🧍🏽‍♀️ E12.1 woman standing: med - ium skin tone\n1F9CD 1F3FD 200D 2640 \; minimally-qua - lified # 🧍🏽‍♀ E12.1 woman standing: medium skin tone\n1F9CD 1F3F - E 200D 2640 FE0F \; fully-qualified # 🧍🏾‍♀ - ️ E12.1 woman standing: medium-dark skin tone\n1F9CD 1F3FE 200D 2640 - \; minimally-qualified # 🧍🏾‍♀ E12.1 woman stan - ding: medium-dark skin tone\n1F9CD 1F3FF 200D 2640 FE0F \; - fully-qualified # 🧍🏿‍♀️ E12.1 woman standing: dark skin t - one\n1F9CD 1F3FF 200D 2640 \; minimally-qualified # - 🧍🏿‍♀ E12.1 woman standing: dark skin tone\n1F9CE - \; fully-qualified # 🧎 E12.1 person kneeling\ - n1F9CE 1F3FB \; fully-qualified # 🧎 - 🏻 E12.1 person kneeling: light skin tone\n1F9CE 1F3FC - \; fully-qualified # 🧎🏼 E12.1 person kneeling: med - ium-light skin tone\n1F9CE 1F3FD \; fully-q - ualified # 🧎🏽 E12.1 person kneeling: medium skin tone\n1F9CE 1F3 - FE \; fully-qualified # 🧎🏾 E12.1 - person kneeling: medium-dark skin tone\n1F9CE 1F3FF - \; fully-qualified # 🧎🏿 E12.1 person kneeling: dark ski - n tone\n1F9CE 200D 2642 FE0F \; fully-qualified - # 🧎‍♂️ E12.1 man kneeling\n1F9CE 200D 2642 - \; minimally-qualified # 🧎‍♂ E12.1 man kneeling\n1F9CE 1F3FB 2 - 00D 2642 FE0F \; fully-qualified # 🧎🏻‍♂️ E - 12.1 man kneeling: light skin tone\n1F9CE 1F3FB 200D 2642 - \; minimally-qualified # 🧎🏻‍♂ E12.1 man kneeling: light ski - n tone\n1F9CE 1F3FC 200D 2642 FE0F \; fully-qualified - # 🧎🏼‍♂️ E12.1 man kneeling: medium-light skin tone\n1F9CE 1F3F - C 200D 2642 \; minimally-qualified # ��🏼‍♂ - E12.1 man kneeling: medium-light skin tone\n1F9CE 1F3FD 200D 2642 FE0F - \; fully-qualified # 🧎🏽‍♂️ E12.1 man kneelin - g: medium skin tone\n1F9CE 1F3FD 200D 2642 \; minimal - ly-qualified # 🧎🏽‍♂ E12.1 man kneeling: medium skin tone\n1F9CE - 1F3FE 200D 2642 FE0F \; fully-qualified # 🧎🏾‍ - ♂️ E12.1 man kneeling: medium-dark skin tone\n1F9CE 1F3FE 200D 2642 - \; minimally-qualified # 🧎🏾‍♂ E12.1 man kneel - ing: medium-dark skin tone\n1F9CE 1F3FF 200D 2642 FE0F \; - fully-qualified # 🧎🏿‍♂️ E12.1 man kneeling: dark skin tone - \n1F9CE 1F3FF 200D 2642 \; minimally-qualified # 🧎 - 🏿‍♂ E12.1 man kneeling: dark skin tone\n1F9CE 200D 2640 FE0F - \; fully-qualified # 🧎‍♀️ E12.1 woman kneelin - g\n1F9CE 200D 2640 \; minimally-qualified # - 🧎‍♀ E12.1 woman kneeling\n1F9CE 1F3FB 200D 2640 FE0F - \; fully-qualified # 🧎🏻‍♀️ E12.1 woman kneeling: light s - kin tone\n1F9CE 1F3FB 200D 2640 \; minimally-qualifie - d # 🧎🏻‍♀ E12.1 woman kneeling: light skin tone\n1F9CE 1F3FC 200D - 2640 FE0F \; fully-qualified # 🧎🏼‍♀️ E12. - 1 woman kneeling: medium-light skin tone\n1F9CE 1F3FC 200D 2640 - \; minimally-qualified # 🧎��‍♀ E12.1 woman kneeling: - medium-light skin tone\n1F9CE 1F3FD 200D 2640 FE0F \; ful - ly-qualified # 🧎🏽‍♀️ E12.1 woman kneeling: medium skin ton - e\n1F9CE 1F3FD 200D 2640 \; minimally-qualified # - 🧎🏽‍♀ E12.1 woman kneeling: medium skin tone\n1F9CE 1F3FE 200D 26 - 40 FE0F \; fully-qualified # 🧎🏾‍♀️ E12.1 w - oman kneeling: medium-dark skin tone\n1F9CE 1F3FE 200D 2640 - \; minimally-qualified # 🧎🏾‍♀ E12.1 woman kneeling: mediu - m-dark skin tone\n1F9CE 1F3FF 200D 2640 FE0F \; fully-qual - ified # 🧎🏿‍♀️ E12.1 woman kneeling: dark skin tone\n1F9CE - 1F3FF 200D 2640 \; minimally-qualified # 🧎🏿‍ - ♀ E12.1 woman kneeling: dark skin tone\n1F9D1 200D 1F9AF - \; fully-qualified # 🧑‍🦯 E12.1 person with probing - cane\n1F9D1 1F3FB 200D 1F9AF \; fully-qualified # - 🧑🏻‍🦯 E12.1 person with probing cane: light skin tone\n1F9D1 1F3 - FC 200D 1F9AF \; fully-qualified # 🧑🏼‍🦯 - E12.1 person with probing cane: medium-light skin tone\n1F9D1 1F3FD 200D - 1F9AF \; fully-qualified # 🧑🏽‍🦯 E12.1 p - erson with probing cane: medium skin tone\n1F9D1 1F3FE 200D 1F9AF - \; fully-qualified # 🧑🏾‍🦯 E12.1 person with pro - bing cane: medium-dark skin tone\n1F9D1 1F3FF 200D 1F9AF - \; fully-qualified # 🧑🏿‍🦯 E12.1 person with probing cane - : dark skin tone\n1F468 200D 1F9AF \; fully-qual - ified # 👨‍🦯 E12.1 man with probing cane\n1F468 1F3FB 200D 1F9A - F \; fully-qualified # 👨🏻‍🦯 E12.1 man w - ith probing cane: light skin tone\n1F468 1F3FC 200D 1F9AF - \; fully-qualified # 👨🏼‍🦯 E12.1 man with probing cane: - medium-light skin tone\n1F468 1F3FD 200D 1F9AF \; full - y-qualified # 👨🏽‍🦯 E12.1 man with probing cane: medium skin - tone\n1F468 1F3FE 200D 1F9AF \; fully-qualified # - 👨🏾‍🦯 E12.1 man with probing cane: medium-dark skin tone\n1F468 - 1F3FF 200D 1F9AF \; fully-qualified # 👨🏿‍ - 🦯 E12.1 man with probing cane: dark skin tone\n1F469 200D 1F9AF - \; fully-qualified # 👩‍🦯 E12.1 woman with p - robing cane\n1F469 1F3FB 200D 1F9AF \; fully-qualified - # 👩🏻‍🦯 E12.1 woman with probing cane: light skin tone\n1F4 - 69 1F3FC 200D 1F9AF \; fully-qualified # 👩🏼 - ‍🦯 E12.1 woman with probing cane: medium-light skin tone\n1F469 1F3FD - 200D 1F9AF \; fully-qualified # 👩🏽‍🦯 E - 12.1 woman with probing cane: medium skin tone\n1F469 1F3FE 200D 1F9AF - \; fully-qualified # 👩��‍🦯 E12.1 woman wi - th probing cane: medium-dark skin tone\n1F469 1F3FF 200D 1F9AF - \; fully-qualified # 👩🏿‍🦯 E12.1 woman with probing - cane: dark skin tone\n1F9D1 200D 1F9BC \; fully - -qualified # 🧑‍🦼 E12.1 person in motorized wheelchair\n1F9D1 1 - F3FB 200D 1F9BC \; fully-qualified # 🧑🏻‍ - 🦼 E12.1 person in motorized wheelchair: light skin tone\n1F9D1 1F3FC 20 - 0D 1F9BC \; fully-qualified # 🧑🏼‍🦼 E12. - 1 person in motorized wheelchair: medium-light skin tone\n1F9D1 1F3FD 200D - 1F9BC \; fully-qualified # 🧑🏽‍🦼 E12.1 - person in motorized wheelchair: medium skin tone\n1F9D1 1F3FE 200D 1F9BC - \; fully-qualified # 🧑🏾‍🦼 E12.1 person i - n motorized wheelchair: medium-dark skin tone\n1F9D1 1F3FF 200D 1F9BC - \; fully-qualified # 🧑🏿‍🦼 E12.1 person in m - otorized wheelchair: dark skin tone\n1F468 200D 1F9BC - \; fully-qualified # 👨‍🦼 E12.1 man in motorized wheelcha - ir\n1F468 1F3FB 200D 1F9BC \; fully-qualified # - 👨🏻‍🦼 E12.1 man in motorized wheelchair: light skin tone\n1F468 - 1F3FC 200D 1F9BC \; fully-qualified # 👨🏼‍ - 🦼 E12.1 man in motorized wheelchair: medium-light skin tone\n1F468 1F3F - D 200D 1F9BC \; fully-qualified # 👨🏽‍🦼 - E12.1 man in motorized wheelchair: medium skin tone\n1F468 1F3FE 200D 1F9B - C \; fully-qualified # 👨🏾‍🦼 E12.1 man i - n motorized wheelchair: medium-dark skin tone\n1F468 1F3FF 200D 1F9BC - \; fully-qualified # 👨🏿‍🦼 E12.1 man in moto - rized wheelchair: dark skin tone\n1F469 200D 1F9BC - \; fully-qualified # 👩‍🦼 E12.1 woman in motorized wheelchai - r\n1F469 1F3FB 200D 1F9BC \; fully-qualified # - 👩🏻‍🦼 E12.1 woman in motorized wheelchair: light skin tone\n1F46 - 9 1F3FC 200D 1F9BC \; fully-qualified # 👩🏼 - ‍🦼 E12.1 woman in motorized wheelchair: medium-light skin tone\n1F469 - 1F3FD 200D 1F9BC \; fully-qualified # 👩🏽‍ - 🦼 E12.1 woman in motorized wheelchair: medium skin tone\n1F469 1F3FE 20 - 0D 1F9BC \; fully-qualified # 👩🏾‍🦼 E12. - 1 woman in motorized wheelchair: medium-dark skin tone\n1F469 1F3FF 200D 1 - F9BC \; fully-qualified # 👩🏿‍🦼 E12.1 wo - man in motorized wheelchair: dark skin tone\n1F9D1 200D 1F9BD - \; fully-qualified # 🧑‍🦽 E12.1 person in manual - wheelchair\n1F9D1 1F3FB 200D 1F9BD \; fully-qualified - # 🧑🏻‍🦽 E12.1 person in manual wheelchair: light skin tone\n - 1F9D1 1F3FC 200D 1F9BD \; fully-qualified # 🧑 - 🏼‍🦽 E12.1 person in manual wheelchair: medium-light skin tone\n1F9 - D1 1F3FD 200D 1F9BD \; fully-qualified # 🧑🏽 - ‍🦽 E12.1 person in manual wheelchair: medium skin tone\n1F9D1 1F3FE 2 - 00D 1F9BD \; fully-qualified # 🧑🏾‍🦽 E12 - .1 person in manual wheelchair: medium-dark skin tone\n1F9D1 1F3FF 200D 1F - 9BD \; fully-qualified # 🧑🏿‍🦽 E12.1 per - son in manual wheelchair: dark skin tone\n1F468 200D 1F9BD - \; fully-qualified # 👨‍🦽 E12.1 man in manual wheelc - hair\n1F468 1F3FB 200D 1F9BD \; fully-qualified # - 👨🏻‍🦽 E12.1 man in manual wheelchair: light skin tone\n1F468 1F3 - FC 200D 1F9BD \; fully-qualified # 👨🏼‍🦽 - E12.1 man in manual wheelchair: medium-light skin tone\n1F468 1F3FD 200D - 1F9BD \; fully-qualified # 👨🏽‍🦽 E12.1 m - an in manual wheelchair: medium skin tone\n1F468 1F3FE 200D 1F9BD - \; fully-qualified # 👨🏾‍🦽 E12.1 man in manual w - heelchair: medium-dark skin tone\n1F468 1F3FF 200D 1F9BD - \; fully-qualified # 👨🏿‍🦽 E12.1 man in manual wheelchair - : dark skin tone\n1F469 200D 1F9BD \; fully-qual - ified # 👩‍🦽 E12.1 woman in manual wheelchair\n1F469 1F3FB 200D - 1F9BD \; fully-qualified # 👩🏻‍🦽 E12.1 - woman in manual wheelchair: light skin tone\n1F469 1F3FC 200D 1F9BD - \; fully-qualified # 👩🏼‍🦽 E12.1 woman in manu - al wheelchair: medium-light skin tone\n1F469 1F3FD 200D 1F9BD - \; fully-qualified # 👩🏽‍🦽 E12.1 woman in manual whe - elchair: medium skin tone\n1F469 1F3FE 200D 1F9BD \; f - ully-qualified # 👩🏾‍�� E12.1 woman in manual wheelchair: m - edium-dark skin tone\n1F469 1F3FF 200D 1F9BD \; fully- - qualified # 👩🏿‍🦽 E12.1 woman in manual wheelchair: dark ski - n tone\n1F3C3 \; fully-qualified - # 🏃 E2.0 person running\n1F3C3 1F3FB \; - fully-qualified # 🏃🏻 E2.0 person running: light skin tone\n1F3C3 - 1F3FC \; fully-qualified # 🏃🏼 E2 - .0 person running: medium-light skin tone\n1F3C3 1F3FD - \; fully-qualified # 🏃🏽 E2.0 person running: medium - skin tone\n1F3C3 1F3FE \; fully-qualified - # 🏃🏾 E2.0 person running: medium-dark skin tone\n1F3C3 1F3FF - \; fully-qualified # 🏃🏿 E2.0 person r - unning: dark skin tone\n1F3C3 200D 2642 FE0F \; full - y-qualified # 🏃‍♂️ E4.0 man running\n1F3C3 200D 2642 - \; minimally-qualified # 🏃‍♂ E4.0 man running\n1 - F3C3 1F3FB 200D 2642 FE0F \; fully-qualified # 🏃 - 🏻‍♂️ E4.0 man running: light skin tone\n1F3C3 1F3FB 200D 2642 - \; minimally-qualified # 🏃🏻‍♂ E4.0 man running - : light skin tone\n1F3C3 1F3FC 200D 2642 FE0F \; fully-qua - lified # 🏃🏼‍♂️ E4.0 man running: medium-light skin tone\n1 - F3C3 1F3FC 200D 2642 \; minimally-qualified # 🏃 - 🏼‍♂ E4.0 man running: medium-light skin tone\n1F3C3 1F3FD 200D 2642 - FE0F \; fully-qualified # 🏃🏽‍♂️ E4.0 man - running: medium skin tone\n1F3C3 1F3FD 200D 2642 \; m - inimally-qualified # 🏃🏽‍♂ E4.0 man running: medium skin tone\n1F - 3C3 1F3FE 200D 2642 FE0F \; fully-qualified # 🏃🏾 - ‍♂️ E4.0 man running: medium-dark skin tone\n1F3C3 1F3FE 200D 2642 - \; minimally-qualified # 🏃🏾‍♂ E4.0 man runni - ng: medium-dark skin tone\n1F3C3 1F3FF 200D 2642 FE0F \; f - ully-qualified # 🏃🏿‍♂️ E4.0 man running: dark skin tone\n1 - F3C3 1F3FF 200D 2642 \; minimally-qualified # 🏃 - 🏿‍♂ E4.0 man running: dark skin tone\n1F3C3 200D 2640 FE0F - \; fully-qualified # 🏃‍♀️ E4.0 woman running\n1 - F3C3 200D 2640 \; minimally-qualified # 🏃‍ - ♀ E4.0 woman running\n1F3C3 1F3FB 200D 2640 FE0F \; full - y-qualified # 🏃🏻‍♀️ E4.0 woman running: light skin tone\n1 - F3C3 1F3FB 200D 2640 \; minimally-qualified # 🏃 - 🏻‍♀ E4.0 woman running: light skin tone\n1F3C3 1F3FC 200D 2640 FE0F - \; fully-qualified # 🏃🏼‍♀️ E4.0 woman run - ning: medium-light skin tone\n1F3C3 1F3FC 200D 2640 \ - ; minimally-qualified # 🏃🏼‍♀ E4.0 woman running: medium-light sk - in tone\n1F3C3 1F3FD 200D 2640 FE0F \; fully-qualified - # 🏃🏽‍♀️ E4.0 woman running: medium skin tone\n1F3C3 1F3FD 200 - D 2640 \; minimally-qualified # 🏃🏽‍♀ E4.0 w - oman running: medium skin tone\n1F3C3 1F3FE 200D 2640 FE0F - \; fully-qualified # 🏃🏾‍♀️ E4.0 woman running: medium-dar - k skin tone\n1F3C3 1F3FE 200D 2640 \; minimally-quali - fied # ��🏾‍♀ E4.0 woman running: medium-dark skin tone\n1F3C3 1 - F3FF 200D 2640 FE0F \; fully-qualified # 🏃🏿‍ - ♀️ E4.0 woman running: dark skin tone\n1F3C3 1F3FF 200D 2640 - \; minimally-qualified # 🏃🏿‍♀ E4.0 woman running: da - rk skin tone\n1F483 \; fully-qualifie - d # 💃 E2.0 woman dancing\n1F483 1F3FB - \; fully-qualified # 💃🏻 E2.0 woman dancing: light skin tone\n1 - F483 1F3FC \; fully-qualified # 💃 - 🏼 E2.0 woman dancing: medium-light skin tone\n1F483 1F3FD - \; fully-qualified # 💃🏽 E2.0 woman dancing: me - dium skin tone\n1F483 1F3FE \; fully-qualif - ied # 💃🏾 E2.0 woman dancing: medium-dark skin tone\n1F483 1F3FF - \; fully-qualified # 💃🏿 E2.0 woma - n dancing: dark skin tone\n1F57A \; f - ully-qualified # 🕺 E4.0 man dancing\n1F57A 1F3FB - \; fully-qualified # 🕺🏻 E4.0 man dancing: light ski - n tone\n1F57A 1F3FC \; fully-qualified - # 🕺🏼 E4.0 man dancing: medium-light skin tone\n1F57A 1F3FD - \; fully-qualified # 🕺🏽 E4.0 man dancing: - medium skin tone\n1F57A 1F3FE \; fully-qual - ified # 🕺🏾 E4.0 man dancing: medium-dark skin tone\n1F57A 1F3FF - \; fully-qualified # 🕺🏿 E4.0 man - dancing: dark skin tone\n1F574 FE0F \; ful - ly-qualified # 🕴️ E2.0 man in suit levitating\n1F574 - \; unqualified # 🕴 E2.0 man in suit le - vitating\n1F574 1F3FB \; fully-qualified - # 🕴🏻 E4.0 man in suit levitating: light skin tone\n1F574 1F3FC - \; fully-qualified # 🕴🏼 E4.0 man in - suit levitating: medium-light skin tone\n1F574 1F3FD - \; fully-qualified # 🕴🏽 E4.0 man in suit levitating: m - edium skin tone\n1F574 1F3FE \; fully-quali - fied # 🕴🏾 E4.0 man in suit levitating: medium-dark skin tone\n1F - 574 1F3FF \; fully-qualified # 🕴🏿 - E4.0 man in suit levitating: dark skin tone\n1F46F - \; fully-qualified # 👯 E2.0 people with bunny ears\n - 1F46F 200D 2642 FE0F \; fully-qualified # 👯 - ‍♂️ E4.0 men with bunny ears\n1F46F 200D 2642 - \; minimally-qualified # 👯‍♂ E4.0 men with bunny ears\n1F46F 2 - 00D 2640 FE0F \; fully-qualified # 👯‍♀️ - E4.0 women with bunny ears\n1F46F 200D 2640 \; - minimally-qualified # 👯‍♀ E4.0 women with bunny ears\n1F9D6 - \; fully-qualified # 🧖 E5.0 person i - n steamy room\n1F9D6 1F3FB \; fully-qualifi - ed # 🧖🏻 E5.0 person in steamy room: light skin tone\n1F9D6 1F3FC - \; fully-qualified # 🧖🏼 E5.0 per - son in steamy room: medium-light skin tone\n1F9D6 1F3FD - \; fully-qualified # 🧖🏽 E5.0 person in steamy room: - medium skin tone\n1F9D6 1F3FE \; fully-qua - lified # 🧖🏾 E5.0 person in steamy room: medium-dark skin tone\n1 - F9D6 1F3FF \; fully-qualified # 🧖 - 🏿 E5.0 person in steamy room: dark skin tone\n1F9D6 200D 2642 FE0F - \; fully-qualified # 🧖‍♂️ E5.0 man in steam - y room\n1F9D6 200D 2642 \; minimally-qualified - # 🧖‍♂ E5.0 man in steamy room\n1F9D6 1F3FB 200D 2642 FE0F - \; fully-qualified # 🧖🏻‍♂️ E5.0 man in steamy room: - light skin tone\n1F9D6 1F3FB 200D 2642 \; minimally- - qualified # 🧖🏻‍♂ E5.0 man in steamy room: light skin tone\n1F9D6 - 1F3FC 200D 2642 FE0F \; fully-qualified # 🧖🏼‍ - ♂️ E5.0 man in steamy room: medium-light skin tone\n1F9D6 1F3FC 200D 2 - 642 \; minimally-qualified # 🧖🏼‍♂ E5.0 man - in steamy room: medium-light skin tone\n1F9D6 1F3FD 200D 2642 FE0F - \; fully-qualified # 🧖🏽‍♂️ E5.0 man in steamy roo - m: medium skin tone\n1F9D6 1F3FD 200D 2642 \; minimal - ly-qualified # 🧖🏽‍♂ E5.0 man in steamy room: medium skin tone\n1 - F9D6 1F3FE 200D 2642 FE0F \; fully-qualified # 🧖 - 🏾‍♂️ E5.0 man in steamy room: medium-dark skin tone\n1F9D6 1F3FE - 200D 2642 \; minimally-qualified # 🧖🏾‍♂ E5. - 0 man in steamy room: medium-dark skin tone\n1F9D6 1F3FF 200D 2642 FE0F - \; fully-qualified # 🧖🏿‍♂️ E5.0 man in steam - y room: dark skin tone\n1F9D6 1F3FF 200D 2642 \; mini - mally-qualified # 🧖🏿‍♂ E5.0 man in steamy room: dark skin tone\n - 1F9D6 200D 2640 FE0F \; fully-qualified # 🧖 - ‍♀️ E5.0 woman in steamy room\n1F9D6 200D 2640 - \; minimally-qualified # 🧖‍♀ E5.0 woman in steamy room\n1F9D6 - 1F3FB 200D 2640 FE0F \; fully-qualified # 🧖🏻‍ - ♀️ E5.0 woman in steamy room: light skin tone\n1F9D6 1F3FB 200D 2640 - \; minimally-qualified # 🧖🏻‍♀ E5.0 woman in - steamy room: light skin tone\n1F9D6 1F3FC 200D 2640 FE0F \ - ; fully-qualified # 🧖🏼‍♀️ E5.0 woman in steamy room: mediu - m-light skin tone\n1F9D6 1F3FC 200D 2640 \; minimally - -qualified # 🧖🏼‍♀ E5.0 woman in steamy room: medium-light skin t - one\n1F9D6 1F3FD 200D 2640 FE0F \; fully-qualified # - 🧖🏽‍♀️ E5.0 woman in steamy room: medium skin tone\n1F9D6 1F3FD - 200D 2640 \; minimally-qualified # 🧖🏽‍♀ E5 - .0 woman in steamy room: medium skin tone\n1F9D6 1F3FE 200D 2640 FE0F - \; fully-qualified # 🧖🏾‍♀️ E5.0 woman in steam - y room: medium-dark skin tone\n1F9D6 1F3FE 200D 2640 - \; minimally-qualified # 🧖🏾‍♀ E5.0 woman in steamy room: medium- - dark skin tone\n1F9D6 1F3FF 200D 2640 FE0F \; fully-qualif - ied # 🧖🏿‍♀️ E5.0 woman in steamy room: dark skin tone\n1F9 - D6 1F3FF 200D 2640 \; minimally-qualified # 🧖🏿 - ‍♀ E5.0 woman in steamy room: dark skin tone\n1F9D7 - \; fully-qualified # 🧗 E5.0 person climbing\n1F9 - D7 1F3FB \; fully-qualified # 🧗🏻 - E5.0 person climbing: light skin tone\n1F9D7 1F3FC - \; fully-qualified # ��🏼 E5.0 person climbing: medium-l - ight skin tone\n1F9D7 1F3FD \; fully-qualif - ied # 🧗🏽 E5.0 person climbing: medium skin tone\n1F9D7 1F3FE - \; fully-qualified # 🧗🏾 E5.0 person - climbing: medium-dark skin tone\n1F9D7 1F3FF - \; fully-qualified # 🧗🏿 E5.0 person climbing: dark skin tone\n - 1F9D7 200D 2642 FE0F \; fully-qualified # 🧗 - ‍♂️ E5.0 man climbing\n1F9D7 200D 2642 \; - minimally-qualified # 🧗‍♂ E5.0 man climbing\n1F9D7 1F3FB 200D 2642 - FE0F \; fully-qualified # 🧗🏻‍♂️ E5.0 man - climbing: light skin tone\n1F9D7 1F3FB 200D 2642 \; m - inimally-qualified # 🧗🏻‍♂ E5.0 man climbing: light skin tone\n1F - 9D7 1F3FC 200D 2642 FE0F \; fully-qualified # 🧗🏼 - ‍♂️ E5.0 man climbing: medium-light skin tone\n1F9D7 1F3FC 200D 2642 - \; minimally-qualified # 🧗🏼‍♂ E5.0 man cli - mbing: medium-light skin tone\n1F9D7 1F3FD 200D 2642 FE0F - \; fully-qualified # 🧗🏽‍♂️ E5.0 man climbing: medium skin - tone\n1F9D7 1F3FD 200D 2642 \; minimally-qualified # - 🧗🏽‍♂ E5.0 man climbing: medium skin tone\n1F9D7 1F3FE 200D 2642 - FE0F \; fully-qualified # 🧗🏾‍♂️ E5.0 man c - limbing: medium-dark skin tone\n1F9D7 1F3FE 200D 2642 - \; minimally-qualified # 🧗🏾‍♂ E5.0 man climbing: medium-dark sk - in tone\n1F9D7 1F3FF 200D 2642 FE0F \; fully-qualified - # 🧗🏿‍♂️ E5.0 man climbing: dark skin tone\n1F9D7 1F3FF 200D 2 - 642 \; minimally-qualified # 🧗🏿‍♂ E5.0 man - climbing: dark skin tone\n1F9D7 200D 2640 FE0F \; fu - lly-qualified # 🧗‍♀️ E5.0 woman climbing\n1F9D7 200D 2640 - \; minimally-qualified # 🧗‍♀ E5.0 woman cli - mbing\n1F9D7 1F3FB 200D 2640 FE0F \; fully-qualified # - 🧗🏻‍♀️ E5.0 woman climbing: light skin tone\n1F9D7 1F3FB 200D - 2640 \; minimally-qualified # 🧗🏻‍♀ E5.0 wom - an climbing: light skin tone\n1F9D7 1F3FC 200D 2640 FE0F \ - ; fully-qualified # 🧗🏼‍♀️ E5.0 woman climbing: medium-ligh - t skin tone\n1F9D7 1F3FC 200D 2640 \; minimally-quali - fied # 🧗🏼‍♀ E5.0 woman climbing: medium-light skin tone\n1F9D7 1 - F3FD 200D 2640 FE0F \; fully-qualified # 🧗🏽‍ - ♀️ E5.0 woman climbing: medium skin tone\n1F9D7 1F3FD 200D 2640 - \; minimally-qualified # 🧗🏽‍♀ E5.0 woman climbing - : medium skin tone\n1F9D7 1F3FE 200D 2640 FE0F \; fully-qu - alified # 🧗🏾‍♀️ E5.0 woman climbing: medium-dark skin tone - \n1F9D7 1F3FE 200D 2640 \; minimally-qualified # 🧗 - 🏾‍♀ E5.0 woman climbing: medium-dark skin tone\n1F9D7 1F3FF 200D 26 - 40 FE0F \; fully-qualified # 🧗🏿‍♀️ E5.0 wo - man climbing: dark skin tone\n1F9D7 1F3FF 200D 2640 \ - ; minimally-qualified # 🧗🏿‍♀ E5.0 woman climbing: dark skin tone - \n\n# subgroup: person-sport\n1F93A \ - ; fully-qualified # 🤺 E4.0 person fencing\n1F3C7 - \; fully-qualified # 🏇 E2.0 horse racing\n1F3C7 - 1F3FB \; fully-qualified # 🏇🏻 E4. - 0 horse racing: light skin tone\n1F3C7 1F3FC - \; fully-qualified # 🏇🏼 E4.0 horse racing: medium-light skin t - one\n1F3C7 1F3FD \; fully-qualified # - 🏇🏽 E4.0 horse racing: medium skin tone\n1F3C7 1F3FE - \; fully-qualified # 🏇🏾 E4.0 horse racing: medium - -dark skin tone\n1F3C7 1F3FF \; fully-quali - fied # 🏇🏿 E4.0 horse racing: dark skin tone\n26F7 FE0F - \; fully-qualified # ⛷️ E2.0 skier\n26F7 - \; unqualified # ⛷ E2.0 ski - er\n1F3C2 \; fully-qualified # - 🏂 E2.0 snowboarder\n1F3C2 1F3FB \; fully - -qualified # 🏂🏻 E4.0 snowboarder: light skin tone\n1F3C2 1F3FC - \; fully-qualified # 🏂🏼 E4.0 snowb - oarder: medium-light skin tone\n1F3C2 1F3FD - \; fully-qualified # 🏂🏽 E4.0 snowboarder: medium skin tone\n1F3 - C2 1F3FE \; fully-qualified # 🏂🏾 - E4.0 snowboarder: medium-dark skin tone\n1F3C2 1F3FF - \; fully-qualified # 🏂🏿 E4.0 snowboarder: dark skin to - ne\n1F3CC FE0F \; fully-qualified # - 🏌️ E2.0 person golfing\n1F3CC \; - unqualified # 🏌 E2.0 person golfing\n1F3CC 1F3FB - \; fully-qualified # 🏌🏻 E4.0 person golfing: l - ight skin tone\n1F3CC 1F3FC \; fully-qualif - ied # 🏌🏼 E4.0 person golfing: medium-light skin tone\n1F3CC 1F3F - D \; fully-qualified # 🏌🏽 E4.0 pe - rson golfing: medium skin tone\n1F3CC 1F3FE - \; fully-qualified # 🏌🏾 E4.0 person golfing: medium-dark skin t - one\n1F3CC 1F3FF \; fully-qualified # - 🏌🏿 E4.0 person golfing: dark skin tone\n1F3CC FE0F 200D 2642 FE0F - \; fully-qualified # 🏌️‍♂️ E4.0 man golfing\ - n1F3CC 200D 2642 FE0F \; unqualified # 🏌 - ‍♂️ E4.0 man golfing\n1F3CC FE0F 200D 2642 \; - unqualified # 🏌️‍♂ E4.0 man golfing\n1F3CC 200D 2642 - \; unqualified # 🏌‍♂ E4.0 man golfin - g\n1F3CC 1F3FB 200D 2642 FE0F \; fully-qualified # - 🏌🏻‍♂️ E4.0 man golfing: light skin tone\n1F3CC 1F3FB 200D 2642 - \; minimally-qualified # 🏌🏻‍♂ E4.0 man gol - fing: light skin tone\n1F3CC 1F3FC 200D 2642 FE0F \; fully - -qualified # 🏌🏼‍♂️ E4.0 man golfing: medium-light skin ton - e\n1F3CC 1F3FC 200D 2642 \; minimally-qualified # - 🏌🏼‍♂ E4.0 man golfing: medium-light skin tone\n1F3CC 1F3FD 200D - 2642 FE0F \; fully-qualified # 🏌🏽‍♂️ E4.0 - man golfing: medium skin tone\n1F3CC 1F3FD 200D 2642 - \; minimally-qualified # 🏌🏽‍♂ E4.0 man golfing: medium skin tone - \n1F3CC 1F3FE 200D 2642 FE0F \; fully-qualified # 🏌 - 🏾‍♂️ E4.0 man golfing: medium-dark skin tone\n1F3CC 1F3FE 200D 26 - 42 \; minimally-qualified # 🏌🏾‍♂ E4.0 man g - olfing: medium-dark skin tone\n1F3CC 1F3FF 200D 2642 FE0F - \; fully-qualified # 🏌🏿‍♂️ E4.0 man golfing: dark skin ton - e\n1F3CC 1F3FF 200D 2642 \; minimally-qualified # - 🏌🏿‍♂ E4.0 man golfing: dark skin tone\n1F3CC FE0F 200D 2640 FE0F - \; fully-qualified # 🏌️‍♀️ E4.0 woman gol - fing\n1F3CC 200D 2640 FE0F \; unqualified # - 🏌‍♀️ E4.0 woman golfing\n1F3CC FE0F 200D 2640 - \; unqualified # 🏌️‍♀ E4.0 woman golfing\n1F3CC 200D 2 - 640 \; unqualified # 🏌‍♀ E4.0 wo - man golfing\n1F3CC 1F3FB 200D 2640 FE0F \; fully-qualified - # 🏌🏻‍♀️ E4.0 woman golfing: light skin tone\n1F3CC 1F3FB - 200D 2640 \; minimally-qualified # 🏌🏻‍♀ E4. - 0 woman golfing: light skin tone\n1F3CC 1F3FC 200D 2640 FE0F - \; fully-qualified # 🏌🏼‍♀️ E4.0 woman golfing: medium-l - ight skin tone\n1F3CC 1F3FC 200D 2640 \; minimally-qu - alified # 🏌🏼‍♀ E4.0 woman golfing: medium-light skin tone\n1F3CC - 1F3FD 200D 2640 FE0F \; fully-qualified # 🏌🏽‍ - ♀️ E4.0 woman golfing: medium skin tone\n1F3CC 1F3FD 200D 2640 - \; minimally-qualified # 🏌🏽‍♀ E4.0 woman golfing: - medium skin tone\n1F3CC 1F3FE 200D 2640 FE0F \; fully-qual - ified # 🏌🏾‍♀️ E4.0 woman golfing: medium-dark skin tone\n1 - F3CC 1F3FE 200D 2640 \; minimally-qualified # 🏌 - 🏾‍♀ E4.0 woman golfing: medium-dark skin tone\n1F3CC 1F3FF 200D 264 - 0 FE0F \; fully-qualified # 🏌🏿‍♀️ E4.0 wom - an golfing: dark skin tone\n1F3CC 1F3FF 200D 2640 \; - minimally-qualified # 🏌🏿‍♀ E4.0 woman golfing: dark skin tone\n1 - F3C4 \; fully-qualified # 🏄 E2 - .0 person surfing\n1F3C4 1F3FB \; fully-qua - lified # 🏄🏻 E2.0 person surfing: light skin tone\n1F3C4 1F3FC - \; fully-qualified # 🏄🏼 E2.0 person - surfing: medium-light skin tone\n1F3C4 1F3FD - \; fully-qualified # 🏄🏽 E2.0 person surfing: medium skin tone - \n1F3C4 1F3FE \; fully-qualified # 🏄 - 🏾 E2.0 person surfing: medium-dark skin tone\n1F3C4 1F3FF - \; fully-qualified # 🏄🏿 E2.0 person surfing: d - ark skin tone\n1F3C4 200D 2642 FE0F \; fully-qualifi - ed # 🏄‍♂️ E4.0 man surfing\n1F3C4 200D 2642 - \; minimally-qualified # 🏄‍♂ E4.0 man surfing\n1F3C4 1F3F - B 200D 2642 FE0F \; fully-qualified # 🏄🏻‍♂ - ️ E4.0 man surfing: light skin tone\n1F3C4 1F3FB 200D 2642 - \; minimally-qualified # 🏄🏻‍♂ E4.0 man surfing: light sk - in tone\n1F3C4 1F3FC 200D 2642 FE0F \; fully-qualified - # 🏄🏼‍♂️ E4.0 man surfing: medium-light skin tone\n1F3C4 1F3FC - 200D 2642 \; minimally-qualified # 🏄��‍♂ - E4.0 man surfing: medium-light skin tone\n1F3C4 1F3FD 200D 2642 FE0F - \; fully-qualified # 🏄🏽‍♂️ E4.0 man surfing: me - dium skin tone\n1F3C4 1F3FD 200D 2642 \; minimally-qu - alified # 🏄🏽‍♂ E4.0 man surfing: medium skin tone\n1F3C4 1F3FE 2 - 00D 2642 FE0F \; fully-qualified # 🏄🏾‍♂️ E - 4.0 man surfing: medium-dark skin tone\n1F3C4 1F3FE 200D 2642 - \; minimally-qualified # 🏄🏾‍♂ E4.0 man surfing: medium- - dark skin tone\n1F3C4 1F3FF 200D 2642 FE0F \; fully-qualif - ied # 🏄🏿‍♂️ E4.0 man surfing: dark skin tone\n1F3C4 1F3FF - 200D 2642 \; minimally-qualified # 🏄🏿‍♂ E4. - 0 man surfing: dark skin tone\n1F3C4 200D 2640 FE0F - \; fully-qualified # 🏄‍♀️ E4.0 woman surfing\n1F3C4 200D 2640 - \; minimally-qualified # 🏄‍♀ E4.0 woman - surfing\n1F3C4 1F3FB 200D 2640 FE0F \; fully-qualified - # 🏄🏻‍♀️ E4.0 woman surfing: light skin tone\n1F3C4 1F3FB 200 - D 2640 \; minimally-qualified # 🏄🏻‍♀ E4.0 w - oman surfing: light skin tone\n1F3C4 1F3FC 200D 2640 FE0F - \; fully-qualified # 🏄🏼‍♀️ E4.0 woman surfing: medium-ligh - t skin tone\n1F3C4 1F3FC 200D 2640 \; minimally-quali - fied # 🏄🏼‍♀ E4.0 woman surfing: medium-light skin tone\n1F3C4 1F - 3FD 200D 2640 FE0F \; fully-qualified # 🏄🏽‍♀ - ️ E4.0 woman surfing: medium skin tone\n1F3C4 1F3FD 200D 2640 - \; minimally-qualified # 🏄🏽‍♀ E4.0 woman surfing: med - ium skin tone\n1F3C4 1F3FE 200D 2640 FE0F \; fully-qualifi - ed # 🏄🏾‍♀️ E4.0 woman surfing: medium-dark skin tone\n1F3C - 4 1F3FE 200D 2640 \; minimally-qualified # 🏄🏾 - ‍♀ E4.0 woman surfing: medium-dark skin tone\n1F3C4 1F3FF 200D 2640 FE - 0F \; fully-qualified # 🏄🏿‍♀️ E4.0 woman s - urfing: dark skin tone\n1F3C4 1F3FF 200D 2640 \; mini - mally-qualified # 🏄🏿‍♀ E4.0 woman surfing: dark skin tone\n1F6A3 - \; fully-qualified # 🚣 E2.0 p - erson rowing boat\n1F6A3 1F3FB \; fully-qua - lified # 🚣🏻 E2.0 person rowing boat: light skin tone\n1F6A3 1F3F - C \; fully-qualified # 🚣🏼 E2.0 pe - rson rowing boat: medium-light skin tone\n1F6A3 1F3FD - \; fully-qualified # 🚣🏽 E2.0 person rowing boat: medi - um skin tone\n1F6A3 1F3FE \; fully-qualifie - d # 🚣🏾 E2.0 person rowing boat: medium-dark skin tone\n1F6A3 1F3 - FF \; fully-qualified # 🚣�� E2.0 - person rowing boat: dark skin tone\n1F6A3 200D 2642 FE0F - \; fully-qualified # 🚣‍♂️ E4.0 man rowing boat\n1F6A3 2 - 00D 2642 \; minimally-qualified # 🚣‍♂ E4 - .0 man rowing boat\n1F6A3 1F3FB 200D 2642 FE0F \; fully-qu - alified # 🚣🏻‍♂️ E4.0 man rowing boat: light skin tone\n1F6 - A3 1F3FB 200D 2642 \; minimally-qualified # 🚣🏻 - ‍♂ E4.0 man rowing boat: light skin tone\n1F6A3 1F3FC 200D 2642 FE0F - \; fully-qualified # 🚣🏼‍♂️ E4.0 man rowing - boat: medium-light skin tone\n1F6A3 1F3FC 200D 2642 \ - ; minimally-qualified # 🚣🏼‍♂ E4.0 man rowing boat: medium-light - skin tone\n1F6A3 1F3FD 200D 2642 FE0F \; fully-qualified - # 🚣🏽‍♂️ E4.0 man rowing boat: medium skin tone\n1F6A3 1F3FD - 200D 2642 \; minimally-qualified # 🚣🏽‍♂ E4 - .0 man rowing boat: medium skin tone\n1F6A3 1F3FE 200D 2642 FE0F - \; fully-qualified # 🚣🏾‍♂️ E4.0 man rowing boat: me - dium-dark skin tone\n1F6A3 1F3FE 200D 2642 \; minimal - ly-qualified # 🚣🏾‍♂ E4.0 man rowing boat: medium-dark skin tone\ - n1F6A3 1F3FF 200D 2642 FE0F \; fully-qualified # 🚣 - 🏿‍♂️ E4.0 man rowing boat: dark skin tone\n1F6A3 1F3FF 200D 2642 - \; minimally-qualified # 🚣🏿‍♂ E4.0 man rowi - ng boat: dark skin tone\n1F6A3 200D 2640 FE0F \; ful - ly-qualified # 🚣‍♀️ E4.0 woman rowing boat\n1F6A3 200D 2640 - \; minimally-qualified # 🚣‍♀ E4.0 woman r - owing boat\n1F6A3 1F3FB 200D 2640 FE0F \; fully-qualified - # 🚣🏻‍♀️ E4.0 woman rowing boat: light skin tone\n1F6A3 1F3 - FB 200D 2640 \; minimally-qualified # 🚣🏻‍♀ - E4.0 woman rowing boat: light skin tone\n1F6A3 1F3FC 200D 2640 FE0F - \; fully-qualified # 🚣🏼‍♀️ E4.0 woman rowing boa - t: medium-light skin tone\n1F6A3 1F3FC 200D 2640 \; m - inimally-qualified # 🚣🏼‍♀ E4.0 woman rowing boat: medium-light s - kin tone\n1F6A3 1F3FD 200D 2640 FE0F \; fully-qualified - # 🚣🏽‍♀️ E4.0 woman rowing boat: medium skin tone\n1F6A3 1F3F - D 200D 2640 \; minimally-qualified # 🚣🏽‍♀ E - 4.0 woman rowing boat: medium skin tone\n1F6A3 1F3FE 200D 2640 FE0F - \; fully-qualified # 🚣🏾‍♀️ E4.0 woman rowing boa - t: medium-dark skin tone\n1F6A3 1F3FE 200D 2640 \; mi - nimally-qualified # 🚣🏾‍♀ E4.0 woman rowing boat: medium-dark ski - n tone\n1F6A3 1F3FF 200D 2640 FE0F \; fully-qualified - # 🚣🏿‍♀️ E4.0 woman rowing boat: dark skin tone\n1F6A3 1F3FF 20 - 0D 2640 \; minimally-qualified # 🚣🏿‍♀ E4.0 - woman rowing boat: dark skin tone\n1F3CA - \; fully-qualified # 🏊 E2.0 person swimming\n1F3CA 1F3FB - \; fully-qualified # 🏊🏻 E2.0 person swi - mming: light skin tone\n1F3CA 1F3FC \; full - y-qualified # 🏊🏼 E2.0 person swimming: medium-light skin tone\n1 - F3CA 1F3FD \; fully-qualified # 🏊 - 🏽 E2.0 person swimming: medium skin tone\n1F3CA 1F3FE - \; fully-qualified # 🏊🏾 E2.0 person swimming: medi - um-dark skin tone\n1F3CA 1F3FF \; fully-qua - lified # 🏊🏿 E2.0 person swimming: dark skin tone\n1F3CA 200D 264 - 2 FE0F \; fully-qualified # 🏊‍♂️ E4.0 m - an swimming\n1F3CA 200D 2642 \; minimally-quali - fied # 🏊‍♂ E4.0 man swimming\n1F3CA 1F3FB 200D 2642 FE0F - \; fully-qualified # 🏊🏻‍♂️ E4.0 man swimming: light - skin tone\n1F3CA 1F3FB 200D 2642 \; minimally-qualifi - ed # 🏊��‍♂ E4.0 man swimming: light skin tone\n1F3CA 1F3FC 200D - 2642 FE0F \; fully-qualified # 🏊🏼‍♂️ E4.0 - man swimming: medium-light skin tone\n1F3CA 1F3FC 200D 2642 - \; minimally-qualified # 🏊🏼‍♂ E4.0 man swimming: medium- - light skin tone\n1F3CA 1F3FD 200D 2642 FE0F \; fully-quali - fied # 🏊🏽‍♂️ E4.0 man swimming: medium skin tone\n1F3CA 1F - 3FD 200D 2642 \; minimally-qualified # 🏊🏽‍♂ - E4.0 man swimming: medium skin tone\n1F3CA 1F3FE 200D 2642 FE0F - \; fully-qualified # 🏊🏾‍♂️ E4.0 man swimming: mediu - m-dark skin tone\n1F3CA 1F3FE 200D 2642 \; minimally- - qualified # 🏊🏾‍♂ E4.0 man swimming: medium-dark skin tone\n1F3CA - 1F3FF 200D 2642 FE0F \; fully-qualified # 🏊🏿‍ - ♂️ E4.0 man swimming: dark skin tone\n1F3CA 1F3FF 200D 2642 - \; minimally-qualified # 🏊🏿‍♂ E4.0 man swimming: dark - skin tone\n1F3CA 200D 2640 FE0F \; fully-qualified - # 🏊‍♀️ E4.0 woman swimming\n1F3CA 200D 2640 - \; minimally-qualified # 🏊‍♀ E4.0 woman swimming\n1F3CA 1 - F3FB 200D 2640 FE0F \; fully-qualified # 🏊🏻‍ - ♀️ E4.0 woman swimming: light skin tone\n1F3CA 1F3FB 200D 2640 - \; minimally-qualified # 🏊🏻‍♀ E4.0 woman swimming: - light skin tone\n1F3CA 1F3FC 200D 2640 FE0F \; fully-qual - ified # 🏊🏼‍♀️ E4.0 woman swimming: medium-light skin tone\ - n1F3CA 1F3FC 200D 2640 \; minimally-qualified # 🏊 - 🏼‍♀ E4.0 woman swimming: medium-light skin tone\n1F3CA 1F3FD 200D 2 - 640 FE0F \; fully-qualified # 🏊🏽‍♀️ E4.0 w - oman swimming: medium skin tone\n1F3CA 1F3FD 200D 2640 - \; minimally-qualified # 🏊🏽‍♀ E4.0 woman swimming: medium skin - tone\n1F3CA 1F3FE 200D 2640 FE0F \; fully-qualified # - 🏊🏾‍♀️ E4.0 woman swimming: medium-dark skin tone\n1F3CA 1F3FE - 200D 2640 \; minimally-qualified # 🏊🏾‍♀ E4 - .0 woman swimming: medium-dark skin tone\n1F3CA 1F3FF 200D 2640 FE0F - \; fully-qualified # 🏊🏿‍♀️ E4.0 woman swimming: - dark skin tone\n1F3CA 1F3FF 200D 2640 \; minimally-q - ualified # 🏊🏿‍♀ E4.0 woman swimming: dark skin tone\n26F9 FE0F - \; fully-qualified # ⛹️ E2.0 perso - n bouncing ball\n26F9 \; unqualified - # ⛹ E2.0 person bouncing ball\n26F9 1F3FB - \; fully-qualified # ⛹🏻 E2.0 person bouncing ball: lig - ht skin tone\n26F9 1F3FC \; fully-qualifie - d # ⛹🏼 E2.0 person bouncing ball: medium-light skin tone\n26F9 1F - 3FD \; fully-qualified # ⛹🏽 E2.0 - person bouncing ball: medium skin tone\n26F9 1F3FE - \; fully-qualified # ⛹🏾 E2.0 person bouncing ball: mediu - m-dark skin tone\n26F9 1F3FF \; fully-qual - ified # ⛹🏿 E2.0 person bouncing ball: dark skin tone\n26F9 FE0F 2 - 00D 2642 FE0F \; fully-qualified # ⛹️‍♂️ E - 4.0 man bouncing ball\n26F9 200D 2642 FE0F \; unqua - lified # ⛹‍♂️ E4.0 man bouncing ball\n26F9 FE0F 200D 2642 - \; unqualified # ⛹️‍♂ E4.0 man boun - cing ball\n26F9 200D 2642 \; unqualified - # ⛹‍♂ E4.0 man bouncing ball\n26F9 1F3FB 200D 2642 FE0F - \; fully-qualified # ⛹🏻‍♂️ E4.0 man bouncing ball: - light skin tone\n26F9 1F3FB 200D 2642 \; minimally-q - ualified # ⛹🏻‍♂ E4.0 man bouncing ball: light skin tone\n26F9 1F3 - FC 200D 2642 FE0F \; fully-qualified # ⛹🏼‍♂ - ️ E4.0 man bouncing ball: medium-light skin tone\n26F9 1F3FC 200D 2642 - \; minimally-qualified # ⛹🏼‍♂ E4.0 man bounc - ing ball: medium-light skin tone\n26F9 1F3FD 200D 2642 FE0F - \; fully-qualified # ⛹🏽‍♂️ E4.0 man bouncing ball: mediu - m skin tone\n26F9 1F3FD 200D 2642 \; minimally-quali - fied # ⛹🏽‍♂ E4.0 man bouncing ball: medium skin tone\n26F9 1F3FE - 200D 2642 FE0F \; fully-qualified # ⛹🏾‍♂️ - E4.0 man bouncing ball: medium-dark skin tone\n26F9 1F3FE 200D 2642 - \; minimally-qualified # ⛹🏾‍♂ E4.0 man bouncing b - all: medium-dark skin tone\n26F9 1F3FF 200D 2642 FE0F \; - fully-qualified # ⛹🏿‍♂️ E4.0 man bouncing ball: dark skin t - one\n26F9 1F3FF 200D 2642 \; minimally-qualified # - ⛹🏿‍♂ E4.0 man bouncing ball: dark skin tone\n26F9 FE0F 200D 2640 - FE0F \; fully-qualified # ⛹️‍♀️ E4.0 woman - bouncing ball\n26F9 200D 2640 FE0F \; unqualified - # ⛹‍♀️ E4.0 woman bouncing ball\n26F9 FE0F 200D 2640 - \; unqualified # ⛹️‍♀ E4.0 woman bouncin - g ball\n26F9 200D 2640 \; unqualified - # ⛹‍♀ E4.0 woman bouncing ball\n26F9 1F3FB 200D 2640 FE0F - \; fully-qualified # ⛹🏻‍♀️ E4.0 woman bouncing ball: - light skin tone\n26F9 1F3FB 200D 2640 \; minimally- - qualified # ⛹🏻‍♀ E4.0 woman bouncing ball: light skin tone\n26F9 - 1F3FC 200D 2640 FE0F \; fully-qualified # ⛹🏼‍ - ♀️ E4.0 woman bouncing ball: medium-light skin tone\n26F9 1F3FC 200D 2 - 640 \; minimally-qualified # ⛹🏼‍♀ E4.0 woma - n bouncing ball: medium-light skin tone\n26F9 1F3FD 200D 2640 FE0F - \; fully-qualified # ⛹🏽‍♀️ E4.0 woman bouncing ba - ll: medium skin tone\n26F9 1F3FD 200D 2640 \; minima - lly-qualified # ⛹🏽‍♀ E4.0 woman bouncing ball: medium skin tone\n - 26F9 1F3FE 200D 2640 FE0F \; fully-qualified # ⛹ - 🏾‍♀️ E4.0 woman bouncing ball: medium-dark skin tone\n26F9 1F3FE - 200D 2640 \; minimally-qualified # ⛹🏾‍♀ E4. - 0 woman bouncing ball: medium-dark skin tone\n26F9 1F3FF 200D 2640 FE0F - \; fully-qualified # ⛹🏿‍♀️ E4.0 woman bounci - ng ball: dark skin tone\n26F9 1F3FF 200D 2640 \; min - imally-qualified # ⛹🏿‍♀ E4.0 woman bouncing ball: dark skin tone\ - n1F3CB FE0F \; fully-qualified # 🏋 - ️ E2.0 person lifting weights\n1F3CB - \; unqualified # 🏋 E2.0 person lifting weights\n1F3CB 1F3FB - \; fully-qualified # 🏋🏻 E2.0 perso - n lifting weights: light skin tone\n1F3CB 1F3FC - \; fully-qualified # 🏋🏼 E2.0 person lifting weights: medium - -light skin tone\n1F3CB 1F3FD \; fully-qual - ified # 🏋🏽 E2.0 person lifting weights: medium skin tone\n1F3CB - 1F3FE \; fully-qualified # 🏋🏾 E2. - 0 person lifting weights: medium-dark skin tone\n1F3CB 1F3FF - \; fully-qualified # 🏋🏿 E2.0 person lifting we - ights: dark skin tone\n1F3CB FE0F 200D 2642 FE0F \; fully - -qualified # 🏋️‍♂️ E4.0 man lifting weights\n1F3CB 200D 264 - 2 FE0F \; unqualified # 🏋‍♂️ E4.0 m - an lifting weights\n1F3CB FE0F 200D 2642 \; unqualif - ied # 🏋️‍♂ E4.0 man lifting weights\n1F3CB 200D 2642 - \; unqualified # 🏋‍♂ E4.0 man liftin - g weights\n1F3CB 1F3FB 200D 2642 FE0F \; fully-qualified - # 🏋🏻‍♂️ E4.0 man lifting weights: light skin tone\n1F3CB 1F - 3FB 200D 2642 \; minimally-qualified # 🏋🏻‍♂ - E4.0 man lifting weights: light skin tone\n1F3CB 1F3FC 200D 2642 FE0F - \; fully-qualified # 🏋🏼‍♂️ E4.0 man lifting w - eights: medium-light skin tone\n1F3CB 1F3FC 200D 2642 - \; minimally-qualified # 🏋🏼‍♂ E4.0 man lifting weights: medium- - light skin tone\n1F3CB 1F3FD 200D 2642 FE0F \; fully-quali - fied # 🏋🏽‍♂️ E4.0 man lifting weights: medium skin tone\n1 - F3CB 1F3FD 200D 2642 \; minimally-qualified # 🏋 - 🏽‍♂ E4.0 man lifting weights: medium skin tone\n1F3CB 1F3FE 200D 26 - 42 FE0F \; fully-qualified # 🏋🏾‍♂️ E4.0 ma - n lifting weights: medium-dark skin tone\n1F3CB 1F3FE 200D 2642 - \; minimally-qualified # 🏋🏾‍♂ E4.0 man lifting weight - s: medium-dark skin tone\n1F3CB 1F3FF 200D 2642 FE0F \; fu - lly-qualified # 🏋🏿‍♂️ E4.0 man lifting weights: dark skin - tone\n1F3CB 1F3FF 200D 2642 \; minimally-qualified # - 🏋🏿‍♂ E4.0 man lifting weights: dark skin tone\n1F3CB FE0F 200D 2 - 640 FE0F \; fully-qualified # 🏋️‍♀️ E4.0 w - oman lifting weights\n1F3CB 200D 2640 FE0F \; unqual - ified # 🏋‍♀️ E4.0 woman lifting weights\n1F3CB FE0F 200D - 2640 \; unqualified # 🏋️‍♀ E4.0 wom - an lifting weights\n1F3CB 200D 2640 \; unqualif - ied # 🏋‍♀ E4.0 woman lifting weights\n1F3CB 1F3FB 200D 2640 - FE0F \; fully-qualified # 🏋🏻‍♀️ E4.0 woma - n lifting weights: light skin tone\n1F3CB 1F3FB 200D 2640 - \; minimally-qualified # 🏋🏻‍♀ E4.0 woman lifting weights: l - ight skin tone\n1F3CB 1F3FC 200D 2640 FE0F \; fully-qualif - ied # 🏋🏼‍♀️ E4.0 woman lifting weights: medium-light skin - tone\n1F3CB 1F3FC 200D 2640 \; minimally-qualified # - 🏋🏼‍♀ E4.0 woman lifting weights: medium-light skin tone\n1F3CB 1 - F3FD 200D 2640 FE0F \; fully-qualified # 🏋🏽‍ - ♀️ E4.0 woman lifting weights: medium skin tone\n1F3CB 1F3FD 200D 2640 - \; minimally-qualified # 🏋🏽‍♀ E4.0 woman l - ifting weights: medium skin tone\n1F3CB 1F3FE 200D 2640 FE0F - \; fully-qualified # 🏋��‍♀️ E4.0 woman lifting weights - : medium-dark skin tone\n1F3CB 1F3FE 200D 2640 \; min - imally-qualified # 🏋🏾‍♀ E4.0 woman lifting weights: medium-dark - skin tone\n1F3CB 1F3FF 200D 2640 FE0F \; fully-qualified - # 🏋🏿‍♀️ E4.0 woman lifting weights: dark skin tone\n1F3CB 1 - F3FF 200D 2640 \; minimally-qualified # 🏋🏿‍ - ♀ E4.0 woman lifting weights: dark skin tone\n1F6B4 - \; fully-qualified # 🚴 E2.0 person biking\n1F6B4 1 - F3FB \; fully-qualified # 🚴🏻 E2.0 - person biking: light skin tone\n1F6B4 1F3FC - \; fully-qualified # 🚴🏼 E2.0 person biking: medium-light skin - tone\n1F6B4 1F3FD \; fully-qualified # - 🚴🏽 E2.0 person biking: medium skin tone\n1F6B4 1F3FE - \; fully-qualified # 🚴🏾 E2.0 person biking: medi - um-dark skin tone\n1F6B4 1F3FF \; fully-qua - lified # 🚴🏿 E2.0 person biking: dark skin tone\n1F6B4 200D 2642 - FE0F \; fully-qualified # 🚴‍♂️ E4.0 man - biking\n1F6B4 200D 2642 \; minimally-qualified - # 🚴‍♂ E4.0 man biking\n1F6B4 1F3FB 200D 2642 FE0F - \; fully-qualified # 🚴🏻‍♂️ E4.0 man biking: light skin ton - e\n1F6B4 1F3FB 200D 2642 \; minimally-qualified # - 🚴🏻‍♂ E4.0 man biking: light skin tone\n1F6B4 1F3FC 200D 2642 FE0 - F \; fully-qualified # 🚴🏼‍♂️ E4.0 man biki - ng: medium-light skin tone\n1F6B4 1F3FC 200D 2642 \; - minimally-qualified # 🚴🏼‍♂ E4.0 man biking: medium-light skin to - ne\n1F6B4 1F3FD 200D 2642 FE0F \; fully-qualified # - 🚴🏽‍♂️ E4.0 man biking: medium skin tone\n1F6B4 1F3FD 200D 2642 - \; minimally-qualified # 🚴🏽‍♂ E4.0 man bik - ing: medium skin tone\n1F6B4 1F3FE 200D 2642 FE0F \; fully - -qualified # 🚴🏾‍♂️ E4.0 man biking: medium-dark skin tone\ - n1F6B4 1F3FE 200D 2642 \; minimally-qualified # 🚴 - 🏾‍♂ E4.0 man biking: medium-dark skin tone\n1F6B4 1F3FF 200D 2642 F - E0F \; fully-qualified # 🚴🏿‍♂️ E4.0 man bi - king: dark skin tone\n1F6B4 1F3FF 200D 2642 \; minima - lly-qualified # 🚴🏿‍♂ E4.0 man biking: dark skin tone\n1F6B4 200D - 2640 FE0F \; fully-qualified # 🚴‍♀️ E4 - .0 woman biking\n1F6B4 200D 2640 \; minimally-q - ualified # 🚴‍♀ E4.0 woman biking\n1F6B4 1F3FB 200D 2640 FE0F - \; fully-qualified # 🚴🏻‍♀️ E4.0 woman biking: li - ght skin tone\n1F6B4 1F3FB 200D 2640 \; minimally-qua - lified # 🚴🏻‍♀ E4.0 woman biking: light skin tone\n1F6B4 1F3FC 20 - 0D 2640 FE0F \; fully-qualified # 🚴🏼‍♀️ E4 - .0 woman biking: medium-light skin tone\n1F6B4 1F3FC 200D 2640 - \; minimally-qualified # 🚴🏼‍♀ E4.0 woman biking: mediu - m-light skin tone\n1F6B4 1F3FD 200D 2640 FE0F \; fully-qua - lified # 🚴🏽‍♀️ E4.0 woman biking: medium skin tone\n1F6B4 - 1F3FD 200D 2640 \; minimally-qualified # 🚴🏽‍ - ♀ E4.0 woman biking: medium skin tone\n1F6B4 1F3FE 200D 2640 FE0F - \; fully-qualified # 🚴🏾‍♀️ E4.0 woman biking: me - dium-dark skin tone\n1F6B4 1F3FE 200D 2640 \; minimal - ly-qualified # 🚴🏾‍♀ E4.0 woman biking: medium-dark skin tone\n1F - 6B4 1F3FF 200D 2640 FE0F \; fully-qualified # 🚴🏿 - ‍♀️ E4.0 woman biking: dark skin tone\n1F6B4 1F3FF 200D 2640 - \; minimally-qualified # 🚴🏿‍♀ E4.0 woman biking: d - ark skin tone\n1F6B5 \; fully-qualifi - ed # 🚵 E2.0 person mountain biking\n1F6B5 1F3FB - \; fully-qualified # 🚵🏻 E2.0 person mountain biking: - light skin tone\n1F6B5 1F3FC \; fully-qual - ified # 🚵🏼 E2.0 person mountain biking: medium-light skin tone\n - 1F6B5 1F3FD \; fully-qualified # 🚵 - 🏽 E2.0 person mountain biking: medium skin tone\n1F6B5 1F3FE - \; fully-qualified # 🚵🏾 E2.0 person mountai - n biking: medium-dark skin tone\n1F6B5 1F3FF - \; fully-qualified # 🚵🏿 E2.0 person mountain biking: dark skin - tone\n1F6B5 200D 2642 FE0F \; fully-qualified # - 🚵‍♂️ E4.0 man mountain biking\n1F6B5 200D 2642 - \; minimally-qualified # 🚵‍♂ E4.0 man mountain biking\n1F - 6B5 1F3FB 200D 2642 FE0F \; fully-qualified # 🚵🏻 - ‍♂️ E4.0 man mountain biking: light skin tone\n1F6B5 1F3FB 200D 2642 - \; minimally-qualified # 🚵��‍♂ E4.0 man m - ountain biking: light skin tone\n1F6B5 1F3FC 200D 2642 FE0F - \; fully-qualified # 🚵🏼‍♂️ E4.0 man mountain biking: med - ium-light skin tone\n1F6B5 1F3FC 200D 2642 \; minimal - ly-qualified # 🚵🏼‍♂ E4.0 man mountain biking: medium-light skin - tone\n1F6B5 1F3FD 200D 2642 FE0F \; fully-qualified # - 🚵🏽‍♂️ E4.0 man mountain biking: medium skin tone\n1F6B5 1F3FD - 200D 2642 \; minimally-qualified # 🚵🏽‍♂ E4. - 0 man mountain biking: medium skin tone\n1F6B5 1F3FE 200D 2642 FE0F - \; fully-qualified # 🚵🏾‍♂️ E4.0 man mountain bik - ing: medium-dark skin tone\n1F6B5 1F3FE 200D 2642 \; - minimally-qualified # 🚵🏾‍♂ E4.0 man mountain biking: medium-dark - skin tone\n1F6B5 1F3FF 200D 2642 FE0F \; fully-qualified - # 🚵🏿‍♂️ E4.0 man mountain biking: dark skin tone\n1F6B5 1F - 3FF 200D 2642 \; minimally-qualified # 🚵��‍ - ♂ E4.0 man mountain biking: dark skin tone\n1F6B5 200D 2640 FE0F - \; fully-qualified # 🚵‍♀️ E4.0 woman mountain - biking\n1F6B5 200D 2640 \; minimally-qualified - # 🚵‍♀ E4.0 woman mountain biking\n1F6B5 1F3FB 200D 2640 FE0F - \; fully-qualified # 🚵🏻‍♀️ E4.0 woman mountain b - iking: light skin tone\n1F6B5 1F3FB 200D 2640 \; mini - mally-qualified # 🚵🏻‍♀ E4.0 woman mountain biking: light skin to - ne\n1F6B5 1F3FC 200D 2640 FE0F \; fully-qualified # - 🚵🏼‍♀️ E4.0 woman mountain biking: medium-light skin tone\n1F6B - 5 1F3FC 200D 2640 \; minimally-qualified # 🚵🏼 - ‍♀ E4.0 woman mountain biking: medium-light skin tone\n1F6B5 1F3FD 200 - D 2640 FE0F \; fully-qualified # 🚵🏽‍♀️ E4. - 0 woman mountain biking: medium skin tone\n1F6B5 1F3FD 200D 2640 - \; minimally-qualified # 🚵🏽‍♀ E4.0 woman mountain bi - king: medium skin tone\n1F6B5 1F3FE 200D 2640 FE0F \; full - y-qualified # 🚵🏾‍♀️ E4.0 woman mountain biking: medium-dar - k skin tone\n1F6B5 1F3FE 200D 2640 \; minimally-quali - fied # 🚵🏾‍♀ E4.0 woman mountain biking: medium-dark skin tone\n1 - F6B5 1F3FF 200D 2640 FE0F \; fully-qualified # 🚵 - 🏿‍♀️ E4.0 woman mountain biking: dark skin tone\n1F6B5 1F3FF 200D - 2640 \; minimally-qualified # 🚵🏿‍♀ E4.0 wo - man mountain biking: dark skin tone\n1F938 - \; fully-qualified # 🤸 E4.0 person cartwheeling\n1F938 1F3FB - \; fully-qualified # 🤸🏻 E4.0 pers - on cartwheeling: light skin tone\n1F938 1F3FC - \; fully-qualified # 🤸🏼 E4.0 person cartwheeling: medium-ligh - t skin tone\n1F938 1F3FD \; fully-qualified - # 🤸🏽 E4.0 person cartwheeling: medium skin tone\n1F938 1F3FE - \; fully-qualified # 🤸🏾 E4.0 person - cartwheeling: medium-dark skin tone\n1F938 1F3FF - \; fully-qualified # 🤸🏿 E4.0 person cartwheeling: dark sk - in tone\n1F938 200D 2642 FE0F \; fully-qualified - # 🤸‍♂️ E4.0 man cartwheeling\n1F938 200D 2642 - \; minimally-qualified # 🤸‍♂ E4.0 man cartwheeling\n1F938 - 1F3FB 200D 2642 FE0F \; fully-qualified # 🤸🏻‍ - ♂️ E4.0 man cartwheeling: light skin tone\n1F938 1F3FB 200D 2642 - \; minimally-qualified # 🤸��‍♂ E4.0 man cartwhe - eling: light skin tone\n1F938 1F3FC 200D 2642 FE0F \; full - y-qualified # 🤸🏼‍♂️ E4.0 man cartwheeling: medium-light sk - in tone\n1F938 1F3FC 200D 2642 \; minimally-qualified - # 🤸🏼‍♂ E4.0 man cartwheeling: medium-light skin tone\n1F938 1F3 - FD 200D 2642 FE0F \; fully-qualified # 🤸🏽‍♂ - ️ E4.0 man cartwheeling: medium skin tone\n1F938 1F3FD 200D 2642 - \; minimally-qualified # 🤸🏽‍♂ E4.0 man cartwheelin - g: medium skin tone\n1F938 1F3FE 200D 2642 FE0F \; fully-q - ualified # 🤸🏾‍♂️ E4.0 man cartwheeling: medium-dark skin t - one\n1F938 1F3FE 200D 2642 \; minimally-qualified # - 🤸🏾‍♂ E4.0 man cartwheeling: medium-dark skin tone\n1F938 1F3FF 2 - 00D 2642 FE0F \; fully-qualified # 🤸🏿‍♂️ E - 4.0 man cartwheeling: dark skin tone\n1F938 1F3FF 200D 2642 - \; minimally-qualified # 🤸🏿‍♂ E4.0 man cartwheeling: dark - skin tone\n1F938 200D 2640 FE0F \; fully-qualified - # 🤸‍♀️ E4.0 woman cartwheeling\n1F938 200D 2640 - \; minimally-qualified # 🤸‍♀ E4.0 woman cartwheeling\ - n1F938 1F3FB 200D 2640 FE0F \; fully-qualified # 🤸 - 🏻‍♀️ E4.0 woman cartwheeling: light skin tone\n1F938 1F3FB 200D 2 - 640 \; minimally-qualified # 🤸🏻‍♀ E4.0 woma - n cartwheeling: light skin tone\n1F938 1F3FC 200D 2640 FE0F - \; fully-qualified # 🤸🏼‍♀️ E4.0 woman cartwheeling: medi - um-light skin tone\n1F938 1F3FC 200D 2640 \; minimall - y-qualified # 🤸🏼‍♀ E4.0 woman cartwheeling: medium-light skin to - ne\n1F938 1F3FD 200D 2640 FE0F \; fully-qualified # - 🤸🏽‍♀️ E4.0 woman cartwheeling: medium skin tone\n1F938 1F3FD 2 - 00D 2640 \; minimally-qualified # 🤸🏽‍♀ E4.0 - woman cartwheeling: medium skin tone\n1F938 1F3FE 200D 2640 FE0F - \; fully-qualified # 🤸🏾‍♀️ E4.0 woman cartwheeling - : medium-dark skin tone\n1F938 1F3FE 200D 2640 \; min - imally-qualified # 🤸🏾‍♀ E4.0 woman cartwheeling: medium-dark ski - n tone\n1F938 1F3FF 200D 2640 FE0F \; fully-qualified - # 🤸🏿‍♀️ E4.0 woman cartwheeling: dark skin tone\n1F938 1F3FF 2 - 00D 2640 \; minimally-qualified # 🤸🏿‍♀ E4.0 - woman cartwheeling: dark skin tone\n1F93C - \; fully-qualified # 🤼 E4.0 people wrestling\n1F93C 200D 2642 - FE0F \; fully-qualified # 🤼‍♂️ E4.0 me - n wrestling\n1F93C 200D 2642 \; minimally-quali - fied # 🤼‍♂ E4.0 men wrestling\n1F93C 200D 2640 FE0F - \; fully-qualified # 🤼‍♀️ E4.0 women wrestling\n1F93C - 200D 2640 \; minimally-qualified # 🤼‍♀ E - 4.0 women wrestling\n1F93D \; fully-q - ualified # 🤽 E4.0 person playing water polo\n1F93D 1F3FB - \; fully-qualified # 🤽🏻 E4.0 person playing - water polo: light skin tone\n1F93D 1F3FC \ - ; fully-qualified # 🤽🏼 E4.0 person playing water polo: medium-li - ght skin tone\n1F93D 1F3FD \; fully-qualifi - ed # 🤽🏽 E4.0 person playing water polo: medium skin tone\n1F93D - 1F3FE \; fully-qualified # 🤽🏾 E4. - 0 person playing water polo: medium-dark skin tone\n1F93D 1F3FF - \; fully-qualified # 🤽🏿 E4.0 person playing - water polo: dark skin tone\n1F93D 200D 2642 FE0F \; - fully-qualified # 🤽‍♂️ E4.0 man playing water polo\n1F93D 20 - 0D 2642 \; minimally-qualified # 🤽‍♂ E4. - 0 man playing water polo\n1F93D 1F3FB 200D 2642 FE0F \; fu - lly-qualified # 🤽🏻‍♂️ E4.0 man playing water polo: light s - kin tone\n1F93D 1F3FB 200D 2642 \; minimally-qualifie - d # 🤽🏻‍♂ E4.0 man playing water polo: light skin tone\n1F93D 1F3 - FC 200D 2642 FE0F \; fully-qualified # 🤽🏼‍♂ - ️ E4.0 man playing water polo: medium-light skin tone\n1F93D 1F3FC 200D - 2642 \; minimally-qualified # 🤽🏼‍♂ E4.0 man - playing water polo: medium-light skin tone\n1F93D 1F3FD 200D 2642 FE0F - \; fully-qualified # 🤽🏽‍♂️ E4.0 man playing - water polo: medium skin tone\n1F93D 1F3FD 200D 2642 \ - ; minimally-qualified # 🤽🏽‍♂ E4.0 man playing water polo: medium - skin tone\n1F93D 1F3FE 200D 2642 FE0F \; fully-qualified - # 🤽🏾‍♂️ E4.0 man playing water polo: medium-dark skin tone - \n1F93D 1F3FE 200D 2642 \; minimally-qualified # 🤽 - 🏾‍♂ E4.0 man playing water polo: medium-dark skin tone\n1F93D 1F3FF - 200D 2642 FE0F \; fully-qualified # 🤽🏿‍♂️ - E4.0 man playing water polo: dark skin tone\n1F93D 1F3FF 200D 2642 - \; minimally-qualified # 🤽🏿‍♂ E4.0 man playing wa - ter polo: dark skin tone\n1F93D 200D 2640 FE0F \; fu - lly-qualified # 🤽‍♀️ E4.0 woman playing water polo\n1F93D 200 - D 2640 \; minimally-qualified # 🤽‍♀ E4.0 - woman playing water polo\n1F93D 1F3FB 200D 2640 FE0F \; f - ully-qualified # 🤽🏻‍♀️ E4.0 woman playing water polo: ligh - t skin tone\n1F93D 1F3FB 200D 2640 \; minimally-quali - fied # 🤽🏻‍♀ E4.0 woman playing water polo: light skin tone\n1F93 - D 1F3FC 200D 2640 FE0F \; fully-qualified # 🤽🏼 - ‍♀️ E4.0 woman playing water polo: medium-light skin tone\n1F93D 1F3 - FC 200D 2640 \; minimally-qualified # 🤽🏼‍♀ - E4.0 woman playing water polo: medium-light skin tone\n1F93D 1F3FD 200D 26 - 40 FE0F \; fully-qualified # 🤽🏽‍♀️ E4.0 wo - man playing water polo: medium skin tone\n1F93D 1F3FD 200D 2640 - \; minimally-qualified # 🤽🏽‍♀ E4.0 woman playing wate - r polo: medium skin tone\n1F93D 1F3FE 200D 2640 FE0F \; fu - lly-qualified # 🤽🏾‍♀️ E4.0 woman playing water polo: mediu - m-dark skin tone\n1F93D 1F3FE 200D 2640 \; minimally- - qualified # 🤽🏾‍♀ E4.0 woman playing water polo: medium-dark skin - tone\n1F93D 1F3FF 200D 2640 FE0F \; fully-qualified # - 🤽🏿‍♀️ E4.0 woman playing water polo: dark skin tone\n1F93D 1F - 3FF 200D 2640 \; minimally-qualified # 🤽🏿‍♀ - E4.0 woman playing water polo: dark skin tone\n1F93E - \; fully-qualified # 🤾 E4.0 person playing handbal - l\n1F93E 1F3FB \; fully-qualified # - 🤾🏻 E4.0 person playing handball: light skin tone\n1F93E 1F3FC - \; fully-qualified # 🤾🏼 E4.0 person pla - ying handball: medium-light skin tone\n1F93E 1F3FD - \; fully-qualified # 🤾🏽 E4.0 person playing handball: me - dium skin tone\n1F93E 1F3FE \; fully-qualif - ied # 🤾🏾 E4.0 person playing handball: medium-dark skin tone\n1F - 93E 1F3FF \; fully-qualified # 🤾🏿 - E4.0 person playing handball: dark skin tone\n1F93E 200D 2642 FE0F - \; fully-qualified # 🤾‍♂️ E4.0 man playing ha - ndball\n1F93E 200D 2642 \; minimally-qualified - # 🤾‍♂ E4.0 man playing handball\n1F93E 1F3FB 200D 2642 FE0F - \; fully-qualified # 🤾🏻‍♂️ E4.0 man playing handb - all: light skin tone\n1F93E 1F3FB 200D 2642 \; minima - lly-qualified # 🤾🏻‍♂ E4.0 man playing handball: light skin tone\ - n1F93E 1F3FC 200D 2642 FE0F \; fully-qualified # 🤾 - 🏼‍♂️ E4.0 man playing handball: medium-light skin tone\n1F93E 1F3 - FC 200D 2642 \; minimally-qualified # 🤾🏼‍♂ - E4.0 man playing handball: medium-light skin tone\n1F93E 1F3FD 200D 2642 F - E0F \; fully-qualified # 🤾🏽‍♂️ E4.0 man pl - aying handball: medium skin tone\n1F93E 1F3FD 200D 2642 - \; minimally-qualified # 🤾🏽‍♂ E4.0 man playing handball: medi - um skin tone\n1F93E 1F3FE 200D 2642 FE0F \; fully-qualifie - d # 🤾🏾‍♂️ E4.0 man playing handball: medium-dark skin tone - \n1F93E 1F3FE 200D 2642 \; minimally-qualified # 🤾 - 🏾‍♂ E4.0 man playing handball: medium-dark skin tone\n1F93E 1F3FF 2 - 00D 2642 FE0F \; fully-qualified # 🤾🏿‍♂️ E - 4.0 man playing handball: dark skin tone\n1F93E 1F3FF 200D 2642 - \; minimally-qualified # 🤾🏿‍♂ E4.0 man playing handba - ll: dark skin tone\n1F93E 200D 2640 FE0F \; fully-qu - alified # 🤾‍♀️ E4.0 woman playing handball\n1F93E 200D 2640 - \; minimally-qualified # 🤾‍♀ E4.0 woman p - laying handball\n1F93E 1F3FB 200D 2640 FE0F \; fully-quali - fied # 🤾🏻‍♀️ E4.0 woman playing handball: light skin tone\ - n1F93E 1F3FB 200D 2640 \; minimally-qualified # 🤾 - 🏻‍♀ E4.0 woman playing handball: light skin tone\n1F93E 1F3FC 200D - 2640 FE0F \; fully-qualified # 🤾🏼‍♀️ E4.0 - woman playing handball: medium-light skin tone\n1F93E 1F3FC 200D 2640 - \; minimally-qualified # 🤾🏼‍♀ E4.0 woman playin - g handball: medium-light skin tone\n1F93E 1F3FD 200D 2640 FE0F - \; fully-qualified # 🤾🏽‍♀️ E4.0 woman playing handbal - l: medium skin tone\n1F93E 1F3FD 200D 2640 \; minimal - ly-qualified # 🤾🏽‍♀ E4.0 woman playing handball: medium skin ton - e\n1F93E 1F3FE 200D 2640 FE0F \; fully-qualified # - 🤾🏾‍♀️ E4.0 woman playing handball: medium-dark skin tone\n1F93 - E 1F3FE 200D 2640 \; minimally-qualified # 🤾🏾 - ‍♀ E4.0 woman playing handball: medium-dark skin tone\n1F93E 1F3FF 200 - D 2640 FE0F \; fully-qualified # 🤾🏿‍♀️ E4. - 0 woman playing handball: dark skin tone\n1F93E 1F3FF 200D 2640 - \; minimally-qualified # 🤾🏿‍♀ E4.0 woman playing hand - ball: dark skin tone\n1F939 \; fully- - qualified # 🤹 E4.0 person juggling\n1F939 1F3FB - \; fully-qualified # 🤹🏻 E4.0 person juggling: light - skin tone\n1F939 1F3FC \; fully-qualified - # 🤹🏼 E4.0 person juggling: medium-light skin tone\n1F939 1F3FD - \; fully-qualified # 🤹🏽 E4.0 person - juggling: medium skin tone\n1F939 1F3FE \; - fully-qualified # 🤹🏾 E4.0 person juggling: medium-dark skin ton - e\n1F939 1F3FF \; fully-qualified # - 🤹🏿 E4.0 person juggling: dark skin tone\n1F939 200D 2642 FE0F - \; fully-qualified # 🤹‍♂️ E4.0 man juggling\n - 1F939 200D 2642 \; minimally-qualified # 🤹 - ‍♂ E4.0 man juggling\n1F939 1F3FB 200D 2642 FE0F \; fu - lly-qualified # 🤹🏻‍♂️ E4.0 man juggling: light skin tone\n - 1F939 1F3FB 200D 2642 \; minimally-qualified # 🤹 - 🏻‍♂ E4.0 man juggling: light skin tone\n1F939 1F3FC 200D 2642 FE0F - \; fully-qualified # 🤹🏼‍♂️ E4.0 man juggli - ng: medium-light skin tone\n1F939 1F3FC 200D 2642 \; - minimally-qualified # 🤹🏼‍♂ E4.0 man juggling: medium-light skin - tone\n1F939 1F3FD 200D 2642 FE0F \; fully-qualified # - 🤹🏽‍♂️ E4.0 man juggling: medium skin tone\n1F939 1F3FD 200D 26 - 42 \; minimally-qualified # 🤹🏽‍♂ E4.0 man j - uggling: medium skin tone\n1F939 1F3FE 200D 2642 FE0F \; f - ully-qualified # 🤹🏾‍♂️ E4.0 man juggling: medium-dark skin - tone\n1F939 1F3FE 200D 2642 \; minimally-qualified # - 🤹🏾‍♂ E4.0 man juggling: medium-dark skin tone\n1F939 1F3FF 200D - 2642 FE0F \; fully-qualified # 🤹🏿‍♂️ E4.0 - man juggling: dark skin tone\n1F939 1F3FF 200D 2642 - \; minimally-qualified # 🤹🏿‍♂ E4.0 man juggling: dark skin tone\ - n1F939 200D 2640 FE0F \; fully-qualified # 🤹 - ‍♀️ E4.0 woman juggling\n1F939 200D 2640 - \; minimally-qualified # 🤹‍♀ E4.0 woman juggling\n1F939 1F3FB 200D - 2640 FE0F \; fully-qualified # 🤹🏻‍♀️ E4.0 - woman juggling: light skin tone\n1F939 1F3FB 200D 2640 - \; minimally-qualified # 🤹🏻‍♀ E4.0 woman juggling: light skin - tone\n1F939 1F3FC 200D 2640 FE0F \; fully-qualified # - 🤹🏼‍♀️ E4.0 woman juggling: medium-light skin tone\n1F939 1F3FC - 200D 2640 \; minimally-qualified # 🤹🏼‍♀ E4 - .0 woman juggling: medium-light skin tone\n1F939 1F3FD 200D 2640 FE0F - \; fully-qualified # 🤹🏽‍♀️ E4.0 woman juggling - : medium skin tone\n1F939 1F3FD 200D 2640 \; minimall - y-qualified # 🤹🏽‍♀ E4.0 woman juggling: medium skin tone\n1F939 - 1F3FE 200D 2640 FE0F \; fully-qualified # 🤹🏾‍ - ♀️ E4.0 woman juggling: medium-dark skin tone\n1F939 1F3FE 200D 2640 - \; minimally-qualified # 🤹🏾‍♀ E4.0 woman jug - gling: medium-dark skin tone\n1F939 1F3FF 200D 2640 FE0F \ - ; fully-qualified # 🤹🏿‍♀️ E4.0 woman juggling: dark skin t - one\n1F939 1F3FF 200D 2640 \; minimally-qualified # - 🤹🏿‍♀ E4.0 woman juggling: dark skin tone\n\n# subgroup: person-r - esting\n1F9D8 \; fully-qualified - # 🧘 E5.0 person in lotus position\n1F9D8 1F3FB - \; fully-qualified # 🧘🏻 E5.0 person in lotus position: li - ght skin tone\n1F9D8 1F3FC \; fully-qualifi - ed # 🧘🏼 E5.0 person in lotus position: medium-light skin tone\n1 - F9D8 1F3FD \; fully-qualified # 🧘 - 🏽 E5.0 person in lotus position: medium skin tone\n1F9D8 1F3FE - \; fully-qualified # 🧘🏾 E5.0 person in lo - tus position: medium-dark skin tone\n1F9D8 1F3FF - \; fully-qualified # 🧘🏿 E5.0 person in lotus position: dar - k skin tone\n1F9D8 200D 2642 FE0F \; fully-qualified - # 🧘‍♂️ E5.0 man in lotus position\n1F9D8 200D 2642 - \; minimally-qualified # 🧘‍♂ E5.0 man in lotus po - sition\n1F9D8 1F3FB 200D 2642 FE0F \; fully-qualified - # 🧘🏻‍♂️ E5.0 man in lotus position: light skin tone\n1F9D8 1F3 - FB 200D 2642 \; minimally-qualified # 🧘🏻‍♂ - E5.0 man in lotus position: light skin tone\n1F9D8 1F3FC 200D 2642 FE0F - \; fully-qualified # 🧘🏼‍♂️ E5.0 man in lotus - position: medium-light skin tone\n1F9D8 1F3FC 200D 2642 - \; minimally-qualified # 🧘🏼‍♂ E5.0 man in lotus position: me - dium-light skin tone\n1F9D8 1F3FD 200D 2642 FE0F \; fully- - qualified # 🧘🏽‍♂️ E5.0 man in lotus position: medium skin - tone\n1F9D8 1F3FD 200D 2642 \; minimally-qualified # - 🧘🏽‍♂ E5.0 man in lotus position: medium skin tone\n1F9D8 1F3FE 2 - 00D 2642 FE0F \; fully-qualified # 🧘🏾‍♂️ E - 5.0 man in lotus position: medium-dark skin tone\n1F9D8 1F3FE 200D 2642 - \; minimally-qualified # 🧘🏾‍♂ E5.0 man in lot - us position: medium-dark skin tone\n1F9D8 1F3FF 200D 2642 FE0F - \; fully-qualified # 🧘🏿‍♂️ E5.0 man in lotus position - : dark skin tone\n1F9D8 1F3FF 200D 2642 \; minimally- - qualified # 🧘🏿‍♂ E5.0 man in lotus position: dark skin tone\n1F9 - D8 200D 2640 FE0F \; fully-qualified # 🧘‍ - ♀️ E5.0 woman in lotus position\n1F9D8 200D 2640 - \; minimally-qualified # 🧘‍♀ E5.0 woman in lotus position\n1F - 9D8 1F3FB 200D 2640 FE0F \; fully-qualified # 🧘🏻 - ‍♀️ E5.0 woman in lotus position: light skin tone\n1F9D8 1F3FB 200D - 2640 \; minimally-qualified # 🧘🏻‍♀ E5.0 wom - an in lotus position: light skin tone\n1F9D8 1F3FC 200D 2640 FE0F - \; fully-qualified # 🧘🏼‍♀️ E5.0 woman in lotus pos - ition: medium-light skin tone\n1F9D8 1F3FC 200D 2640 - \; minimally-qualified # 🧘🏼‍♀ E5.0 woman in lotus position: medi - um-light skin tone\n1F9D8 1F3FD 200D 2640 FE0F \; fully-qu - alified # 🧘🏽‍♀️ E5.0 woman in lotus position: medium skin - tone\n1F9D8 1F3FD 200D 2640 \; minimally-qualified # - 🧘🏽‍♀ E5.0 woman in lotus position: medium skin tone\n1F9D8 1F3FE - 200D 2640 FE0F \; fully-qualified # 🧘🏾‍♀️ - E5.0 woman in lotus position: medium-dark skin tone\n1F9D8 1F3FE 200D 264 - 0 \; minimally-qualified # 🧘🏾‍♀ E5.0 woman - in lotus position: medium-dark skin tone\n1F9D8 1F3FF 200D 2640 FE0F - \; fully-qualified # 🧘🏿‍♀️ E5.0 woman in lotus - position: dark skin tone\n1F9D8 1F3FF 200D 2640 \; mi - nimally-qualified # 🧘🏿‍♀ E5.0 woman in lotus position: dark skin - tone\n1F6C0 \; fully-qualified # - 🛀 E2.0 person taking bath\n1F6C0 1F3FB - \; fully-qualified # 🛀🏻 E2.0 person taking bath: light skin tone - \n1F6C0 1F3FC \; fully-qualified # 🛀 - 🏼 E2.0 person taking bath: medium-light skin tone\n1F6C0 1F3FD - \; fully-qualified # 🛀🏽 E2.0 person takin - g bath: medium skin tone\n1F6C0 1F3FE \; fu - lly-qualified # 🛀🏾 E2.0 person taking bath: medium-dark skin ton - e\n1F6C0 1F3FF \; fully-qualified # - 🛀🏿 E2.0 person taking bath: dark skin tone\n1F6CC - \; fully-qualified # 🛌 E2.0 person in bed\n1F6CC - 1F3FB \; fully-qualified # 🛌🏻 E4 - .0 person in bed: light skin tone\n1F6CC 1F3FC - \; fully-qualified # 🛌🏼 E4.0 person in bed: medium-light ski - n tone\n1F6CC 1F3FD \; fully-qualified - # 🛌🏽 E4.0 person in bed: medium skin tone\n1F6CC 1F3FE - \; fully-qualified # 🛌🏾 E4.0 person in bed: me - dium-dark skin tone\n1F6CC 1F3FF \; fully-q - ualified # 🛌🏿 E4.0 person in bed: dark skin tone\n\n# subgroup: - family\n1F9D1 200D 1F91D 200D 1F9D1 \; fully-qualified - # 🧑‍🤝‍🧑 E12.1 people holding hands\n1F9D1 1F3FB 200D 1F91D 20 - 0D 1F9D1 1F3FB \; fully-qualified # 🧑🏻‍🤝‍🧑🏻 E12. - 1 people holding hands: light skin tone\n1F9D1 1F3FB 200D 1F91D 200D 1F9D1 - 1F3FC \; fully-qualified # 🧑🏻‍🤝‍🧑🏼 E12.1 people - holding hands: light skin tone\, medium-light skin tone\n1F9D1 1F3FB 200D - 1F91D 200D 1F9D1 1F3FD \; fully-qualified # 🧑🏻‍🤝‍🧑 - 🏽 E12.1 people holding hands: light skin tone\, medium skin tone\n1F9D1 - 1F3FB 200D 1F91D 200D 1F9D1 1F3FE \; fully-qualified # 🧑🏻‍ - 🤝‍🧑🏾 E12.1 people holding hands: light skin tone\, medium-dark - skin tone\n1F9D1 1F3FB 200D 1F91D 200D 1F9D1 1F3FF \; fully-qualified - # 🧑🏻‍🤝‍🧑🏿 E12.1 people holding hands: light skin ton - e\, dark skin tone\n1F9D1 1F3FC 200D 1F91D 200D 1F9D1 1F3FB \; fully-qu - alified # 🧑🏼‍🤝‍🧑🏻 E12.1 people holding hands: mediu - m-light skin tone\, light skin tone\n1F9D1 1F3FC 200D 1F91D 200D 1F9D1 1F3 - FC \; fully-qualified # 🧑🏼‍🤝‍🧑🏼 E12.1 people hol - ding hands: medium-light skin tone\n1F9D1 1F3FC 200D 1F91D 200D 1F9D1 1F3F - D \; fully-qualified # 🧑🏼‍🤝‍🧑🏽 E12.1 people hold - ing hands: medium-light skin tone\, medium skin tone\n1F9D1 1F3FC 200D 1F9 - 1D 200D 1F9D1 1F3FE \; fully-qualified # 🧑🏼‍🤝‍🧑🏾 - E12.1 people holding hands: medium-light skin tone\, medium-dark skin ton - e\n1F9D1 1F3FC 200D 1F91D 200D 1F9D1 1F3FF \; fully-qualified # - 🧑🏼‍🤝‍🧑🏿 E12.1 people holding hands: medium-light skin t - one\, dark skin tone\n1F9D1 1F3FD 200D 1F91D 200D 1F9D1 1F3FB \; fully- - qualified # 🧑🏽‍🤝‍🧑🏻 E12.1 people holding hands: med - ium skin tone\, light skin tone\n1F9D1 1F3FD 200D 1F91D 200D 1F9D1 1F3FC - \; fully-qualified # 🧑🏽‍🤝‍🧑🏼 E12.1 people holding - hands: medium skin tone\, medium-light skin tone\n1F9D1 1F3FD 200D 1F91D - 200D 1F9D1 1F3FD \; fully-qualified # 🧑🏽‍🤝‍🧑🏽 E1 - 2.1 people holding hands: medium skin tone\n1F9D1 1F3FD 200D 1F91D 200D 1F - 9D1 1F3FE \; fully-qualified # 🧑🏽‍🤝‍🧑🏾 E12.1 peo - ple holding hands: medium skin tone\, medium-dark skin tone\n1F9D1 1F3FD 2 - 00D 1F91D 200D 1F9D1 1F3FF \; fully-qualified # 🧑🏽‍🤝‍ - 🧑🏿 E12.1 people holding hands: medium skin tone\, dark skin tone\n1F - 9D1 1F3FE 200D 1F91D 200D 1F9D1 1F3FB \; fully-qualified # 🧑🏾 - ‍🤝‍🧑🏻 E12.1 people holding hands: medium-dark skin tone\, lig - ht skin tone\n1F9D1 1F3FE 200D 1F91D 200D 1F9D1 1F3FC \; fully-qualifie - d # 🧑🏾‍🤝‍🧑🏼 E12.1 people holding hands: medium-dark - skin tone\, medium-light skin tone\n1F9D1 1F3FE 200D 1F91D 200D 1F9D1 1F3 - FD \; fully-qualified # 🧑🏾‍🤝‍🧑🏽 E12.1 people hol - ding hands: medium-dark skin tone\, medium skin tone\n1F9D1 1F3FE 200D 1F9 - 1D 200D 1F9D1 1F3FE \; fully-qualified # 🧑🏾‍🤝‍🧑🏾 - E12.1 people holding hands: medium-dark skin tone\n1F9D1 1F3FE 200D 1F91D - 200D 1F9D1 1F3FF \; fully-qualified # 🧑��‍🤝‍🧑🏿 - E12.1 people holding hands: medium-dark skin tone\, dark skin tone\n1F9D1 - 1F3FF 200D 1F91D 200D 1F9D1 1F3FB \; fully-qualified # 🧑🏿‍ - 🤝‍🧑🏻 E12.1 people holding hands: dark skin tone\, light skin to - ne\n1F9D1 1F3FF 200D 1F91D 200D 1F9D1 1F3FC \; fully-qualified # - 🧑🏿‍🤝‍🧑�� E12.1 people holding hands: dark skin tone\, - medium-light skin tone\n1F9D1 1F3FF 200D 1F91D 200D 1F9D1 1F3FD \; full - y-qualified # 🧑🏿‍🤝‍🧑�� E12.1 people holding hands: - dark skin tone\, medium skin tone\n1F9D1 1F3FF 200D 1F91D 200D 1F9D1 1F3F - E \; fully-qualified # 🧑🏿‍🤝‍🧑🏾 E12.1 people hold - ing hands: dark skin tone\, medium-dark skin tone\n1F9D1 1F3FF 200D 1F91D - 200D 1F9D1 1F3FF \; fully-qualified # 🧑🏿‍🤝‍🧑🏿 E1 - 2.1 people holding hands: dark skin tone\n1F46D - \; fully-qualified # 👭 E2.0 women holding hands\n1F46D 1 - F3FB \; fully-qualified # 👭🏻 E12. - 1 women holding hands: light skin tone\n1F469 1F3FB 200D 1F91D 200D 1F469 - 1F3FC \; fully-qualified # 👩🏻‍🤝‍👩🏼 E12.1 women h - olding hands: light skin tone\, medium-light skin tone\n1F469 1F3FB 200D 1 - F91D 200D 1F469 1F3FD \; fully-qualified # 👩🏻‍🤝‍👩 - 🏽 E12.1 women holding hands: light skin tone\, medium skin tone\n1F469 - 1F3FB 200D 1F91D 200D 1F469 1F3FE \; fully-qualified # 👩🏻‍ - 🤝‍👩🏾 E12.1 women holding hands: light skin tone\, medium-dark s - kin tone\n1F469 1F3FB 200D 1F91D 200D 1F469 1F3FF \; fully-qualified - # 👩🏻‍🤝‍👩🏿 E12.1 women holding hands: light skin tone\ - , dark skin tone\n1F469 1F3FC 200D 1F91D 200D 1F469 1F3FB \; fully-qual - ified # 👩🏼‍🤝‍👩🏻 E12.1 women holding hands: medium-l - ight skin tone\, light skin tone\n1F46D 1F3FC - \; fully-qualified # 👭🏼 E12.1 women holding hands: medium-lig - ht skin tone\n1F469 1F3FC 200D 1F91D 200D 1F469 1F3FD \; fully-qualifie - d # 👩🏼‍🤝‍👩🏽 E12.1 women holding hands: medium-light - skin tone\, medium skin tone\n1F469 1F3FC 200D 1F91D 200D 1F469 1F3FE - \; fully-qualified # 👩🏼‍🤝‍👩🏾 E12.1 women holding ha - nds: medium-light skin tone\, medium-dark skin tone\n1F469 1F3FC 200D 1F91 - D 200D 1F469 1F3FF \; fully-qualified # 👩🏼‍🤝‍👩🏿 - E12.1 women holding hands: medium-light skin tone\, dark skin tone\n1F469 - 1F3FD 200D 1F91D 200D 1F469 1F3FB \; fully-qualified # 👩🏽‍ - 🤝‍👩🏻 E12.1 women holding hands: medium skin tone\, light skin t - one\n1F469 1F3FD 200D 1F91D 200D 1F469 1F3FC \; fully-qualified # - 👩🏽‍🤝‍👩🏼 E12.1 women holding hands: medium skin tone\, m - edium-light skin tone\n1F46D 1F3FD \; fully - -qualified # 👭🏽 E12.1 women holding hands: medium skin tone\n1F4 - 69 1F3FD 200D 1F91D 200D 1F469 1F3FE \; fully-qualified # 👩🏽 - ‍🤝‍👩🏾 E12.1 women holding hands: medium skin tone\, medium-da - rk skin tone\n1F469 1F3FD 200D 1F91D 200D 1F469 1F3FF \; fully-qualifie - d # 👩🏽‍🤝‍👩🏿 E12.1 women holding hands: medium skin - tone\, dark skin tone\n1F469 1F3FE 200D 1F91D 200D 1F469 1F3FB \; fully - -qualified # 👩🏾‍🤝‍👩🏻 E12.1 women holding hands: med - ium-dark skin tone\, light skin tone\n1F469 1F3FE 200D 1F91D 200D 1F469 1F - 3FC \; fully-qualified # 👩🏾‍🤝‍👩🏼 E12.1 women hol - ding hands: medium-dark skin tone\, medium-light skin tone\n1F469 1F3FE 20 - 0D 1F91D 200D 1F469 1F3FD \; fully-qualified # 👩🏾‍🤝‍ - 👩🏽 E12.1 women holding hands: medium-dark skin tone\, medium skin to - ne\n1F46D 1F3FE \; fully-qualified # - 👭🏾 E12.1 women holding hands: medium-dark skin tone\n1F469 1F3FE 200 - D 1F91D 200D 1F469 1F3FF \; fully-qualified # 👩🏾‍🤝‍ - 👩🏿 E12.1 women holding hands: medium-dark skin tone\, dark skin tone - \n1F469 1F3FF 200D 1F91D 200D 1F469 1F3FB \; fully-qualified # 👩 - 🏿‍🤝‍👩🏻 E12.1 women holding hands: dark skin tone\, light s - kin tone\n1F469 1F3FF 200D 1F91D 200D 1F469 1F3FC \; fully-qualified - # 👩🏿‍🤝‍👩🏼 E12.1 women holding hands: dark skin tone\, - medium-light skin tone\n1F469 1F3FF 200D 1F91D 200D 1F469 1F3FD \; ful - ly-qualified # 👩🏿‍🤝‍👩🏽 E12.1 women holding hands: d - ark skin tone\, medium skin tone\n1F469 1F3FF 200D 1F91D 200D 1F469 1F3FE - \; fully-qualified # 👩🏿‍🤝‍👩🏾 E12.1 women holding - hands: dark skin tone\, medium-dark skin tone\n1F46D 1F3FF - \; fully-qualified # 👭🏿 E12.1 women holding han - ds: dark skin tone\n1F46B \; fully-qu - alified # 👫 E2.0 woman and man holding hands\n1F46B 1F3FB - \; fully-qualified # 👫🏻 E12.1 woman and ma - n holding hands: light skin tone\n1F469 1F3FB 200D 1F91D 200D 1F468 1F3FC - \; fully-qualified # 👩🏻‍🤝‍👨🏼 E12.1 woman and man - holding hands: light skin tone\, medium-light skin tone\n1F469 1F3FB 200D - 1F91D 200D 1F468 1F3FD \; fully-qualified # 👩🏻‍🤝‍👨 - 🏽 E12.1 woman and man holding hands: light skin tone\, medium skin tone - \n1F469 1F3FB 200D 1F91D 200D 1F468 1F3FE \; fully-qualified # 👩 - 🏻‍🤝‍👨🏾 E12.1 woman and man holding hands: light skin tone\ - , medium-dark skin tone\n1F469 1F3FB 200D 1F91D 200D 1F468 1F3FF \; ful - ly-qualified # 👩🏻‍🤝‍👨🏿 E12.1 woman and man holding - hands: light skin tone\, dark skin tone\n1F469 1F3FC 200D 1F91D 200D 1F468 - 1F3FB \; fully-qualified # 👩🏼‍🤝‍👨🏻 E12.1 woman - and man holding hands: medium-light skin tone\, light skin tone\n1F46B 1F3 - FC \; fully-qualified # 👫🏼 E12.1 - woman and man holding hands: medium-light skin tone\n1F469 1F3FC 200D 1F91 - D 200D 1F468 1F3FD \; fully-qualified # 👩🏼‍🤝‍👨🏽 - E12.1 woman and man holding hands: medium-light skin tone\, medium skin to - ne\n1F469 1F3FC 200D 1F91D 200D 1F468 1F3FE \; fully-qualified # - 👩🏼‍🤝‍👨🏾 E12.1 woman and man holding hands: medium-light - skin tone\, medium-dark skin tone\n1F469 1F3FC 200D 1F91D 200D 1F468 1F3F - F \; fully-qualified # 👩🏼‍🤝‍👨🏿 E12.1 woman and m - an holding hands: medium-light skin tone\, dark skin tone\n1F469 1F3FD 200 - D 1F91D 200D 1F468 1F3FB \; fully-qualified # 👩🏽‍🤝‍ - 👨🏻 E12.1 woman and man holding hands: medium skin tone\, light skin - tone\n1F469 1F3FD 200D 1F91D 200D 1F468 1F3FC \; fully-qualified # - 👩🏽‍🤝‍👨🏼 E12.1 woman and man holding hands: medium skin - tone\, medium-light skin tone\n1F46B 1F3FD - \; fully-qualified # 👫🏽 E12.1 woman and man holding hands: mediu - m skin tone\n1F469 1F3FD 200D 1F91D 200D 1F468 1F3FE \; fully-qualified - # 👩🏽‍🤝‍👨🏾 E12.1 woman and man holding hands: mediu - m skin tone\, medium-dark skin tone\n1F469 1F3FD 200D 1F91D 200D 1F468 1F3 - FF \; fully-qualified # 👩🏽‍🤝‍👨🏿 E12.1 woman and - man holding hands: medium skin tone\, dark skin tone\n1F469 1F3FE 200D 1F9 - 1D 200D 1F468 1F3FB \; fully-qualified # 👩🏾‍🤝‍👨🏻 - E12.1 woman and man holding hands: medium-dark skin tone\, light skin ton - e\n1F469 1F3FE 200D 1F91D 200D 1F468 1F3FC \; fully-qualified # - 👩🏾‍🤝‍👨🏼 E12.1 woman and man holding hands: medium-dark - skin tone\, medium-light skin tone\n1F469 1F3FE 200D 1F91D 200D 1F468 1F3F - D \; fully-qualified # 👩🏾‍🤝‍👨🏽 E12.1 woman and m - an holding hands: medium-dark skin tone\, medium skin tone\n1F46B 1F3FE - \; fully-qualified # 👫🏾 E12.1 woman - and man holding hands: medium-dark skin tone\n1F469 1F3FE 200D 1F91D 200D - 1F468 1F3FF \; fully-qualified # 👩🏾‍🤝‍👨�� E12. - 1 woman and man holding hands: medium-dark skin tone\, dark skin tone\n1F4 - 69 1F3FF 200D 1F91D 200D 1F468 1F3FB \; fully-qualified # 👩🏿 - ‍🤝‍👨🏻 E12.1 woman and man holding hands: dark skin tone\, lig - ht skin tone\n1F469 1F3FF 200D 1F91D 200D 1F468 1F3FC \; fully-qualifie - d # 👩🏿‍🤝‍👨🏼 E12.1 woman and man holding hands: dark - skin tone\, medium-light skin tone\n1F469 1F3FF 200D 1F91D 200D 1F468 1F3 - FD \; fully-qualified # 👩🏿‍🤝‍👨🏽 E12.1 woman and - man holding hands: dark skin tone\, medium skin tone\n1F469 1F3FF 200D 1F9 - 1D 200D 1F468 1F3FE \; fully-qualified # 👩🏿‍🤝‍👨🏾 - E12.1 woman and man holding hands: dark skin tone\, medium-dark skin tone - \n1F46B 1F3FF \; fully-qualified # 👫 - 🏿 E12.1 woman and man holding hands: dark skin tone\n1F46C - \; fully-qualified # 👬 E2.0 men holding ha - nds\n1F46C 1F3FB \; fully-qualified # - 👬🏻 E12.1 men holding hands: light skin tone\n1F468 1F3FB 200D 1F91D - 200D 1F468 1F3FC \; fully-qualified # 👨🏻‍🤝‍👨🏼 E1 - 2.1 men holding hands: light skin tone\, medium-light skin tone\n1F468 1F3 - FB 200D 1F91D 200D 1F468 1F3FD \; fully-qualified # 👨🏻‍🤝 - ‍👨🏽 E12.1 men holding hands: light skin tone\, medium skin tone\n1 - F468 1F3FB 200D 1F91D 200D 1F468 1F3FE \; fully-qualified # 👨 - 🏻‍🤝‍👨🏾 E12.1 men holding hands: light skin tone\, medium-d - ark skin tone\n1F468 1F3FB 200D 1F91D 200D 1F468 1F3FF \; fully-qualifi - ed # 👨🏻‍🤝‍👨🏿 E12.1 men holding hands: light skin to - ne\, dark skin tone\n1F468 1F3FC 200D 1F91D 200D 1F468 1F3FB \; fully-q - ualified # 👨🏼‍🤝‍👨🏻 E12.1 men holding hands: medium- - light skin tone\, light skin tone\n1F46C 1F3FC - \; fully-qualified # 👬🏼 E12.1 men holding hands: medium-ligh - t skin tone\n1F468 1F3FC 200D 1F91D 200D 1F468 1F3FD \; fully-qualified - # 👨🏼‍🤝‍👨🏽 E12.1 men holding hands: medium-light sk - in tone\, medium skin tone\n1F468 1F3FC 200D 1F91D 200D 1F468 1F3FE \; - fully-qualified # 👨🏼‍🤝‍👨�� E12.1 men holding hands - : medium-light skin tone\, medium-dark skin tone\n1F468 1F3FC 200D 1F91D 2 - 00D 1F468 1F3FF \; fully-qualified # 👨🏼‍🤝‍👨🏿 E12 - .1 men holding hands: medium-light skin tone\, dark skin tone\n1F468 1F3FD - 200D 1F91D 200D 1F468 1F3FB \; fully-qualified # 👨🏽‍🤝 - ‍👨🏻 E12.1 men holding hands: medium skin tone\, light skin tone\n1 - F468 1F3FD 200D 1F91D 200D 1F468 1F3FC \; fully-qualified # 👨 - 🏽‍🤝‍👨🏼 E12.1 men holding hands: medium skin tone\, medium- - light skin tone\n1F46C 1F3FD \; fully-quali - fied # 👬🏽 E12.1 men holding hands: medium skin tone\n1F468 1F3FD - 200D 1F91D 200D 1F468 1F3FE \; fully-qualified # 👨🏽‍🤝 - ‍👨🏾 E12.1 men holding hands: medium skin tone\, medium-dark skin t - one\n1F468 1F3FD 200D 1F91D 200D 1F468 1F3FF \; fully-qualified # - 👨🏽‍🤝‍👨🏿 E12.1 men holding hands: medium skin tone\, dar - k skin tone\n1F468 1F3FE 200D 1F91D 200D 1F468 1F3FB \; fully-qualified - # 👨🏾‍🤝‍👨🏻 E12.1 men holding hands: medium-dark ski - n tone\, light skin tone\n1F468 1F3FE 200D 1F91D 200D 1F468 1F3FC \; fu - lly-qualified # 👨🏾‍🤝‍👨🏼 E12.1 men holding hands: me - dium-dark skin tone\, medium-light skin tone\n1F468 1F3FE 200D 1F91D 200D - 1F468 1F3FD \; fully-qualified # 👨🏾‍🤝‍👨🏽 E12.1 m - en holding hands: medium-dark skin tone\, medium skin tone\n1F46C 1F3FE - \; fully-qualified # 👬🏾 E12.1 men h - olding hands: medium-dark skin tone\n1F468 1F3FE 200D 1F91D 200D 1F468 1F3 - FF \; fully-qualified # 👨🏾‍🤝‍👨🏿 E12.1 men holdin - g hands: medium-dark skin tone\, dark skin tone\n1F468 1F3FF 200D 1F91D 20 - 0D 1F468 1F3FB \; fully-qualified # 👨🏿‍🤝‍👨🏻 E12. - 1 men holding hands: dark skin tone\, light skin tone\n1F468 1F3FF 200D 1F - 91D 200D 1F468 1F3FC \; fully-qualified # 👨🏿‍🤝‍👨 - 🏼 E12.1 men holding hands: dark skin tone\, medium-light skin tone\n1F4 - 68 1F3FF 200D 1F91D 200D 1F468 1F3FD \; fully-qualified # 👨🏿 - ‍🤝‍👨🏽 E12.1 men holding hands: dark skin tone\, medium skin t - one\n1F468 1F3FF 200D 1F91D 200D 1F468 1F3FE \; fully-qualified # - 👨🏿‍🤝‍👨🏾 E12.1 men holding hands: dark skin tone\, mediu - m-dark skin tone\n1F46C 1F3FF \; fully-qual - ified # 👬🏿 E12.1 men holding hands: dark skin tone\n1F48F - \; fully-qualified # 💏 E2.0 kiss\n1F - 469 200D 2764 FE0F 200D 1F48B 200D 1F468 \; fully-qualified # 👩‍ - ❤️‍💋‍👨 E2.0 kiss: woman\, man\n1F469 200D 2764 200D 1F48B 20 - 0D 1F468 \; minimally-qualified # 👩‍❤‍💋‍👨 E2.0 kiss: - woman\, man\n1F468 200D 2764 FE0F 200D 1F48B 200D 1F468 \; fully-qualifie - d # 👨‍❤️‍💋‍👨 E2.0 kiss: man\, man\n1F468 200D 2764 - 200D 1F48B 200D 1F468 \; minimally-qualified # 👨‍❤‍💋‍ - 👨 E2.0 kiss: man\, man\n1F469 200D 2764 FE0F 200D 1F48B 200D 1F469 \; f - ully-qualified # 👩‍❤️‍💋‍👩 E2.0 kiss: woman\, woman\ - n1F469 200D 2764 200D 1F48B 200D 1F469 \; minimally-qualified # 👩 - ‍❤‍💋‍👩 E2.0 kiss: woman\, woman\n1F491 - \; fully-qualified # 💑 E2.0 couple with heart\n1F46 - 9 200D 2764 FE0F 200D 1F468 \; fully-qualified # 👩‍❤ - ️‍👨 E2.0 couple with heart: woman\, man\n1F469 200D 2764 200D 1F468 - \; minimally-qualified # 👩‍❤‍👨 E2.0 couple wi - th heart: woman\, man\n1F468 200D 2764 FE0F 200D 1F468 \; fully - -qualified # 👨‍❤️‍👨 E2.0 couple with heart: man\, man\n1 - F468 200D 2764 200D 1F468 \; minimally-qualified # 👨‍ - ❤‍👨 E2.0 couple with heart: man\, man\n1F469 200D 2764 FE0F 200D 1F - 469 \; fully-qualified # 👩‍❤️‍👩 E2.0 couple w - ith heart: woman\, woman\n1F469 200D 2764 200D 1F469 \; mi - nimally-qualified # 👩‍❤‍👩 E2.0 couple with heart: woman\, woma - n\n1F46A \; fully-qualified # - 👪 E2.0 family\n1F468 200D 1F469 200D 1F466 \; fully-qual - ified # 👨‍👩‍👦 E2.0 family: man\, woman\, boy\n1F468 200D - 1F469 200D 1F467 \; fully-qualified # 👨‍👩‍ - 👧 E2.0 family: man\, woman\, girl\n1F468 200D 1F469 200D 1F467 200D 1F4 - 66 \; fully-qualified # 👨‍👩‍👧‍👦 E2.0 family: man - \, woman\, girl\, boy\n1F468 200D 1F469 200D 1F466 200D 1F466 \; fully - -qualified # 👨‍👩‍👦‍👦 E2.0 family: man\, woman\, boy\ - , boy\n1F468 200D 1F469 200D 1F467 200D 1F467 \; fully-qualified # - 👨‍👩‍👧‍👧 E2.0 family: man\, woman\, girl\, girl\n1F468 2 - 00D 1F468 200D 1F466 \; fully-qualified # 👨‍👨 - ‍👦 E2.0 family: man\, man\, boy\n1F468 200D 1F468 200D 1F467 - \; fully-qualified # 👨‍👨‍👧 E2.0 family: man\, man\ - , girl\n1F468 200D 1F468 200D 1F467 200D 1F466 \; fully-qualified - # 👨‍👨‍👧‍👦 E2.0 family: man\, man\, girl\, boy\n1F468 200 - D 1F468 200D 1F466 200D 1F466 \; fully-qualified # 👨‍👨‍ - 👦‍👦 E2.0 family: man\, man\, boy\, boy\n1F468 200D 1F468 200D 1F46 - 7 200D 1F467 \; fully-qualified # 👨‍👨‍👧‍👧 E2.0 f - amily: man\, man\, girl\, girl\n1F469 200D 1F469 200D 1F466 - \; fully-qualified # 👩‍👩‍👦 E2.0 family: woman\, woman\, - boy\n1F469 200D 1F469 200D 1F467 \; fully-qualified # - 👩‍👩‍👧 E2.0 family: woman\, woman\, girl\n1F469 200D 1F469 200 - D 1F467 200D 1F466 \; fully-qualified # 👩‍👩‍��‍ - 👦 E2.0 family: woman\, woman\, girl\, boy\n1F469 200D 1F469 200D 1F466 - 200D 1F466 \; fully-qualified # 👩‍👩‍👦‍👦 E2.0 fam - ily: woman\, woman\, boy\, boy\n1F469 200D 1F469 200D 1F467 200D 1F467 - \; fully-qualified # 👩‍👩‍👧‍👧 E2.0 family: woman\, w - oman\, girl\, girl\n1F468 200D 1F466 \; fully-qu - alified # 👨‍👦 E4.0 family: man\, boy\n1F468 200D 1F466 200D 1F - 466 \; fully-qualified # 👨‍👦‍👦 E4.0 family - : man\, boy\, boy\n1F468 200D 1F467 \; fully-qua - lified # 👨‍👧 E4.0 family: man\, girl\n1F468 200D 1F467 200D 1F - 466 \; fully-qualified # 👨‍👧‍👦 E4.0 family - : man\, girl\, boy\n1F468 200D 1F467 200D 1F467 \; fully-qu - alified # 👨‍👧‍👧 E4.0 family: man\, girl\, girl\n1F469 200 - D 1F466 \; fully-qualified # 👩‍👦 E4. - 0 family: woman\, boy\n1F469 200D 1F466 200D 1F466 \; fully - -qualified # 👩‍👦‍👦 E4.0 family: woman\, boy\, boy\n1F469 - 200D 1F467 \; fully-qualified # 👩‍👧 - E4.0 family: woman\, girl\n1F469 200D 1F467 200D 1F466 \; f - ully-qualified # 👩‍👧‍👦 E4.0 family: woman\, girl\, boy\n1 - F469 200D 1F467 200D 1F467 \; fully-qualified # 👩‍ - 👧‍👧 E4.0 family: woman\, girl\, girl\n\n# subgroup: person-symbol\ - n1F5E3 FE0F \; fully-qualified # 🗣 - ️ E2.0 speaking head\n1F5E3 \; unqu - alified # 🗣 E2.0 speaking head\n1F464 - \; fully-qualified # 👤 E2.0 bust in silhouette\n1F465 - \; fully-qualified # 👥 E2.0 bus - ts in silhouette\n1F463 \; fully-qual - ified # 👣 E2.0 footprints\n\n# People & Body subtotal: 2398\n# Peo - ple & Body subtotal: 473 w/o modifiers\n\n# group: Component\n\n# subgrou - p: skin-tone\n1F3FB \; component - # 🏻 E2.0 light skin tone\n1F3FC - \; component # 🏼 E2.0 medium-light skin tone\n1F3FD - \; component # 🏽 E2.0 medium - skin tone\n1F3FE \; component - # 🏾 E2.0 medium-dark skin tone\n1F3FF - \; component # 🏿 E2.0 dark skin tone\n\n# subgroup: ha - ir-style\n1F9B0 \; component - # 🦰 E11.0 red hair\n1F9B1 \; com - ponent # 🦱 E11.0 curly hair\n1F9B3 - \; component # 🦳 E11.0 white hair\n1F9B2 - \; component # 🦲 E11.0 bald\n\n# - Component subtotal: 9\n# Component subtotal: 4 w/o modifiers\n\n# group: - Animals & Nature\n\n# subgroup: animal-mammal\n1F435 - \; fully-qualified # 🐵 E2.0 monkey face\n1F412 - \; fully-qualified # 🐒 E2.0 monke - y\n1F98D \; fully-qualified # - 🦍 E4.0 gorilla\n1F9A7 \; fully-qua - lified # 🦧 E12.1 orangutan\n1F436 - \; fully-qualified # 🐶 E2.0 dog face\n1F415 - \; fully-qualified # 🐕 E2.0 dog\n1F9AE - \; fully-qualified # 🦮 E12.1 guide dog\n - 1F415 200D 1F9BA \; fully-qualified # 🐕 - ‍🦺 E12.1 service dog\n1F429 \; f - ully-qualified # 🐩 E2.0 poodle\n1F43A - \; fully-qualified # 🐺 E2.0 wolf\n1F98A - \; fully-qualified # 🦊 E4.0 fox\n1F99D - \; fully-qualified # 🦝 E11.0 raccoon\n1F - 431 \; fully-qualified # 🐱 E2. - 0 cat face\n1F408 \; fully-qualified - # 🐈 E2.0 cat\n1F981 \; fully-q - ualified # 🦁 E2.0 lion\n1F42F - \; fully-qualified # 🐯 E2.0 tiger face\n1F405 - \; fully-qualified # 🐅 E2.0 tiger\n1F406 - \; fully-qualified # 🐆 E2.0 leopard\n1F4 - 34 \; fully-qualified # 🐴 E2.0 - horse face\n1F40E \; fully-qualified - # 🐎 E2.0 horse\n1F984 \; full - y-qualified # 🦄 E2.0 unicorn\n1F993 - \; fully-qualified # 🦓 E5.0 zebra\n1F98C - \; fully-qualified # 🦌 E4.0 deer\n1F42E - \; fully-qualified # 🐮 E2.0 cow face\n1F - 402 \; fully-qualified # 🐂 E2. - 0 ox\n1F403 \; fully-qualified # - 🐃 E2.0 water buffalo\n1F404 \; ful - ly-qualified # 🐄 E2.0 cow\n1F437 - \; fully-qualified # 🐷 E2.0 pig face\n1F416 - \; fully-qualified # 🐖 E2.0 pig\n1F417 - \; fully-qualified # 🐗 E2.0 boar\n1F43D - \; fully-qualified # 🐽 E2.0 pig - nose\n1F40F \; fully-qualified # - 🐏 E2.0 ram\n1F411 \; fully-qualif - ied # 🐑 E2.0 ewe\n1F410 \; ful - ly-qualified # 🐐 E2.0 goat\n1F42A - \; fully-qualified # 🐪 E2.0 camel\n1F42B - \; fully-qualified # 🐫 E2.0 two-hump camel\n1F999 - \; fully-qualified # 🦙 E11.0 lla - ma\n1F992 \; fully-qualified # - 🦒 E5.0 giraffe\n1F418 \; fully-qua - lified # 🐘 E2.0 elephant\n1F98F - \; fully-qualified # 🦏 E4.0 rhinoceros\n1F99B - \; fully-qualified # 🦛 E11.0 hippopotamus\n1F42D - \; fully-qualified # 🐭 E2.0 mo - use face\n1F401 \; fully-qualified - # 🐁 E2.0 mouse\n1F400 \; fully-q - ualified # 🐀 E2.0 rat\n1F439 \ - ; fully-qualified # 🐹 E2.0 hamster\n1F430 - \; fully-qualified # 🐰 E2.0 rabbit face\n1F407 - \; fully-qualified # 🐇 E2.0 rabbit\n1F - 43F FE0F \; fully-qualified # 🐿️ - E2.0 chipmunk\n1F43F \; unqualified - # 🐿 E2.0 chipmunk\n1F994 \; - fully-qualified # 🦔 E5.0 hedgehog\n1F987 - \; fully-qualified # 🦇 E4.0 bat\n1F43B - \; fully-qualified # 🐻 E2.0 bear\n1F428 - \; fully-qualified # 🐨 E2.0 koala\n1 - F43C \; fully-qualified # 🐼 E2 - .0 panda\n1F9A5 \; fully-qualified - # 🦥 E12.1 sloth\n1F9A6 \; fully- - qualified # 🦦 E12.1 otter\n1F9A8 - \; fully-qualified # 🦨 E12.1 skunk\n1F998 - \; fully-qualified # 🦘 E11.0 kangaroo\n1F9A1 - \; fully-qualified # 🦡 E11.0 badger\n - 1F43E \; fully-qualified # 🐾 E - 2.0 paw prints\n\n# subgroup: animal-bird\n1F983 - \; fully-qualified # 🦃 E2.0 turkey\n1F414 - \; fully-qualified # 🐔 E2.0 chicken\n1F413 - \; fully-qualified # 🐓 E2.0 ro - oster\n1F423 \; fully-qualified # - 🐣 E2.0 hatching chick\n1F424 \; f - ully-qualified # 🐤 E2.0 baby chick\n1F425 - \; fully-qualified # 🐥 E2.0 front-facing baby chick\n1F - 426 \; fully-qualified # 🐦 E2. - 0 bird\n1F427 \; fully-qualified - # 🐧 E2.0 penguin\n1F54A FE0F \; fully-q - ualified # 🕊️ E2.0 dove\n1F54A - \; unqualified # 🕊 E2.0 dove\n1F985 - \; fully-qualified # 🦅 E4.0 eagle\n1F986 - \; fully-qualified # 🦆 E4.0 duck\n1F9A2 - \; fully-qualified # 🦢 E11.0 swan - \n1F989 \; fully-qualified # 🦉 - E4.0 owl\n1F9A9 \; fully-qualified - # 🦩 E12.1 flamingo\n1F99A \; fu - lly-qualified # 🦚 E11.0 peacock\n1F99C - \; fully-qualified # 🦜 E11.0 parrot\n\n# subgroup: animal- - amphibian\n1F438 \; fully-qualified - # 🐸 E2.0 frog\n\n# subgroup: animal-reptile\n1F40A - \; fully-qualified # 🐊 E2.0 crocodile\n1F422 - \; fully-qualified # 🐢 E2.0 turt - le\n1F98E \; fully-qualified # - 🦎 E4.0 lizard\n1F40D \; fully-qual - ified # 🐍 E2.0 snake\n1F432 \; - fully-qualified # 🐲 E2.0 dragon face\n1F409 - \; fully-qualified # 🐉 E2.0 dragon\n1F995 - \; fully-qualified # 🦕 E5.0 sauropod\n1F - 996 \; fully-qualified # 🦖 E5. - 0 T-Rex\n\n# subgroup: animal-marine\n1F433 - \; fully-qualified # 🐳 E2.0 spouting whale\n1F40B - \; fully-qualified # 🐋 E2.0 whale\n1F42C - \; fully-qualified # 🐬 E2.0 d - olphin\n1F41F \; fully-qualified - # 🐟 E2.0 fish\n1F420 \; fully-qual - ified # 🐠 E2.0 tropical fish\n1F421 - \; fully-qualified # 🐡 E2.0 blowfish\n1F988 - \; fully-qualified # �� E4.0 shark\n1F419 - \; fully-qualified # 🐙 E2.0 octopu - s\n1F41A \; fully-qualified # - 🐚 E2.0 spiral shell\n\n# subgroup: animal-bug\n1F40C - \; fully-qualified # 🐌 E2.0 snail\n1F98B - \; fully-qualified # 🦋 E4.0 butterfly - \n1F41B \; fully-qualified # 🐛 - E2.0 bug\n1F41C \; fully-qualified - # 🐜 E2.0 ant\n1F41D \; fully-qu - alified # 🐝 E2.0 honeybee\n1F41E - \; fully-qualified # 🐞 E2.0 lady beetle\n1F997 - \; fully-qualified # 🦗 E5.0 cricket\n1F577 FE0F - \; fully-qualified # 🕷️ E2.0 spi - der\n1F577 \; unqualified # - 🕷 E2.0 spider\n1F578 FE0F \; fully-qual - ified # 🕸️ E2.0 spider web\n1F578 - \; unqualified # 🕸 E2.0 spider web\n1F982 - \; fully-qualified # 🦂 E2.0 scorpion\n1F99F - \; fully-qualified # 🦟 E11.0 mo - squito\n1F9A0 \; fully-qualified - # 🦠 E11.0 microbe\n\n# subgroup: plant-flower\n1F490 - \; fully-qualified # 💐 E2.0 bouquet\n1F338 - \; fully-qualified # 🌸 E2.0 cherry - blossom\n1F4AE \; fully-qualified - # 💮 E2.0 white flower\n1F3F5 FE0F \; f - ully-qualified # 🏵️ E2.0 rosette\n1F3F5 - \; unqualified # 🏵 E2.0 rosette\n1F339 - \; fully-qualified # 🌹 E2.0 rose\n1F940 - \; fully-qualified # 🥀 E4.0 wilt - ed flower\n1F33A \; fully-qualified - # 🌺 E2.0 hibiscus\n1F33B \; ful - ly-qualified # 🌻 E2.0 sunflower\n1F33C - \; fully-qualified # 🌼 E2.0 blossom\n1F337 - \; fully-qualified # 🌷 E2.0 tulip\n\n# subgro - up: plant-other\n1F331 \; fully-quali - fied # 🌱 E2.0 seedling\n1F332 - \; fully-qualified # 🌲 E2.0 evergreen tree\n1F333 - \; fully-qualified # 🌳 E2.0 deciduous tree\n1F3 - 34 \; fully-qualified # 🌴 E2.0 - palm tree\n1F335 \; fully-qualified - # 🌵 E2.0 cactus\n1F33E \; full - y-qualified # 🌾 E2.0 sheaf of rice\n1F33F - \; fully-qualified # 🌿 E2.0 herb\n2618 FE0F - \; fully-qualified # ☘️ E2.0 shamrock\n2618 - \; unqualified # ☘ E2.0 sh - amrock\n1F340 \; fully-qualified - # 🍀 E2.0 four leaf clover\n1F341 \ - ; fully-qualified # 🍁 E2.0 maple leaf\n1F342 - \; fully-qualified # 🍂 E2.0 fallen leaf\n1F343 - \; fully-qualified # 🍃 E2.0 leaf fl - uttering in wind\n\n# Animals & Nature subtotal: 133\n# Animals & Nature - subtotal: 133 w/o modifiers\n\n# group: Food & Drink\n\n# subgroup: food- - fruit\n1F347 \; fully-qualified # - 🍇 E2.0 grapes\n1F348 \; fully-qua - lified # 🍈 E2.0 melon\n1F349 \ - ; fully-qualified # 🍉 E2.0 watermelon\n1F34A - \; fully-qualified # 🍊 E2.0 tangerine\n1F34B - \; fully-qualified # 🍋 E2.0 lemon\n1F - 34C \; fully-qualified # 🍌 E2. - 0 banana\n1F34D \; fully-qualified - # 🍍 E2.0 pineapple\n1F96D \; ful - ly-qualified # 🥭 E11.0 mango\n1F34E - \; fully-qualified # 🍎 E2.0 red apple\n1F34F - \; fully-qualified # 🍏 E2.0 green apple\n1F350 - \; fully-qualified # 🍐 E2.0 p - ear\n1F351 \; fully-qualified # - 🍑 E2.0 peach\n1F352 \; fully-quali - fied # 🍒 E2.0 cherries\n1F353 - \; fully-qualified # 🍓 E2.0 strawberry\n1F95D - \; fully-qualified # 🥝 E4.0 kiwi fruit\n1F345 - \; fully-qualified # 🍅 E2.0 tomato\ - n1F965 \; fully-qualified # 🥥 - E5.0 coconut\n\n# subgroup: food-vegetable\n1F951 - \; fully-qualified # 🥑 E4.0 avocado\n1F346 - \; fully-qualified # 🍆 E2.0 eggplant\n1F9 - 54 \; fully-qualified # 🥔 E4.0 - potato\n1F955 \; fully-qualified - # 🥕 E4.0 carrot\n1F33D \; fully-q - ualified # 🌽 E2.0 ear of corn\n1F336 FE0F - \; fully-qualified # 🌶️ E2.0 hot pepper\n1F336 - \; unqualified # 🌶 E2.0 hot pepper\n1 - F952 \; fully-qualified # 🥒 E4 - .0 cucumber\n1F96C \; fully-qualified - # 🥬 E11.0 leafy green\n1F966 - \; fully-qualified # 🥦 E5.0 broccoli\n1F9C4 - \; fully-qualified # 🧄 E12.1 garlic\n1F9C5 - \; fully-qualified # 🧅 E12.1 onion\n1F34 - 4 \; fully-qualified # �� E2. - 0 mushroom\n1F95C \; fully-qualified - # 🥜 E4.0 peanuts\n1F330 \; ful - ly-qualified # 🌰 E2.0 chestnut\n\n# subgroup: food-prepared\n1F35E - \; fully-qualified # 🍞 E2.0 br - ead\n1F950 \; fully-qualified # - 🥐 E4.0 croissant\n1F956 \; fully-q - ualified # 🥖 E4.0 baguette bread\n1F968 - \; fully-qualified # 🥨 E5.0 pretzel\n1F96F - \; fully-qualified # 🥯 E11.0 bagel\n1F95E - \; fully-qualified # 🥞 E4.0 panc - akes\n1F9C7 \; fully-qualified # - 🧇 E12.1 waffle\n1F9C0 \; fully-qua - lified # 🧀 E2.0 cheese wedge\n1F356 - \; fully-qualified # 🍖 E2.0 meat on bone\n1F357 - \; fully-qualified # 🍗 E2.0 poultry leg\n1F - 969 \; fully-qualified # 🥩 E5. - 0 cut of meat\n1F953 \; fully-qualifi - ed # 🥓 E4.0 bacon\n1F354 \; fu - lly-qualified # 🍔 E2.0 hamburger\n1F35F - \; fully-qualified # 🍟 E2.0 french fries\n1F355 - \; fully-qualified # 🍕 E2.0 pizza\n1F32 - D \; fully-qualified # 🌭 E2.0 - hot dog\n1F96A \; fully-qualified - # 🥪 E5.0 sandwich\n1F32E \; fully - -qualified # 🌮 E2.0 taco\n1F32F - \; fully-qualified # 🌯 E2.0 burrito\n1F959 - \; fully-qualified # 🥙 E4.0 stuffed flatbread\n1F9C6 - \; fully-qualified # 🧆 E12.1 - falafel\n1F95A \; fully-qualified - # 🥚 E4.0 egg\n1F373 \; fully-qual - ified # 🍳 E2.0 cooking\n1F958 - \; fully-qualified # 🥘 E4.0 shallow pan of food\n1F372 - \; fully-qualified # 🍲 E2.0 pot of food\n1 - F963 \; fully-qualified # 🥣 E5 - .0 bowl with spoon\n1F957 \; fully-qu - alified # 🥗 E4.0 green salad\n1F37F - \; fully-qualified # 🍿 E2.0 popcorn\n1F9C8 - \; fully-qualified # 🧈 E12.1 butter\n1F9C2 - \; fully-qualified # 🧂 E11.0 salt\n - 1F96B \; fully-qualified # 🥫 E - 5.0 canned food\n\n# subgroup: food-asian\n1F371 - \; fully-qualified # 🍱 E2.0 bento box\n1F358 - \; fully-qualified # 🍘 E2.0 rice cracker - \n1F359 \; fully-qualified # 🍙 - E2.0 rice ball\n1F35A \; fully-quali - fied # 🍚 E2.0 cooked rice\n1F35B - \; fully-qualified # 🍛 E2.0 curry rice\n1F35C - \; fully-qualified # 🍜 E2.0 steaming bowl\n1F35D - \; fully-qualified # 🍝 E2.0 s - paghetti\n1F360 \; fully-qualified - # 🍠 E2.0 roasted sweet potato\n1F362 - \; fully-qualified # 🍢 E2.0 oden\n1F363 - \; fully-qualified # 🍣 E2.0 sushi\n1F364 - \; fully-qualified # 🍤 E2.0 fried shrimp\ - n1F365 \; fully-qualified # 🍥 - E2.0 fish cake with swirl\n1F96E \; f - ully-qualified # 🥮 E11.0 moon cake\n1F361 - \; fully-qualified # 🍡 E2.0 dango\n1F95F - \; fully-qualified # 🥟 E5.0 dumpling\n1F960 - \; fully-qualified # 🥠 E5.0 fo - rtune cookie\n1F961 \; fully-qualifie - d # 🥡 E5.0 takeout box\n\n# subgroup: food-marine\n1F980 - \; fully-qualified # 🦀 E2.0 crab\n1F99E - \; fully-qualified # 🦞 E11.0 l - obster\n1F990 \; fully-qualified - # 🦐 E4.0 shrimp\n1F991 \; fully-qu - alified # 🦑 E4.0 squid\n1F9AA - \; fully-qualified # 🦪 E12.1 oyster\n\n# subgroup: food-sweet\n1F36 - 6 \; fully-qualified # 🍦 E2.0 - soft ice cream\n1F367 \; fully-qualif - ied # 🍧 E2.0 shaved ice\n1F368 - \; fully-qualified # 🍨 E2.0 ice cream\n1F369 - \; fully-qualified # 🍩 E2.0 doughnut\n1F36A - \; fully-qualified # 🍪 E2.0 cookie\n1 - F382 \; fully-qualified # 🎂 E2 - .0 birthday cake\n1F370 \; fully-qual - ified # 🍰 E2.0 shortcake\n1F9C1 - \; fully-qualified # 🧁 E11.0 cupcake\n1F967 - \; fully-qualified # 🥧 E5.0 pie\n1F36B - \; fully-qualified # 🍫 E2.0 chocolate bar\ - n1F36C \; fully-qualified # 🍬 - E2.0 candy\n1F36D \; fully-qualified - # 🍭 E2.0 lollipop\n1F36E \; fu - lly-qualified # 🍮 E2.0 custard\n1F36F - \; fully-qualified # 🍯 E2.0 honey pot\n\n# subgroup: drink\ - n1F37C \; fully-qualified # 🍼 - E2.0 baby bottle\n1F95B \; fully-qual - ified # 🥛 E4.0 glass of milk\n2615 - \; fully-qualified # ☕ E2.0 hot beverage\n1F375 - \; fully-qualified # 🍵 E2.0 teacup without h - andle\n1F376 \; fully-qualified # - 🍶 E2.0 sake\n1F37E \; fully-quali - fied # 🍾 E2.0 bottle with popping cork\n1F377 - \; fully-qualified # 🍷 E2.0 wine glass\n1F378 - \; fully-qualified # 🍸 E2.0 cocktai - l glass\n1F379 \; fully-qualified - # 🍹 E2.0 tropical drink\n1F37A \; - fully-qualified # �� E2.0 beer mug\n1F37B - \; fully-qualified # 🍻 E2.0 clinking beer mugs\n1F942 - \; fully-qualified # 🥂 E4.0 c - linking glasses\n1F943 \; fully-quali - fied # 🥃 E4.0 tumbler glass\n1F964 - \; fully-qualified # 🥤 E5.0 cup with straw\n1F9C3 - \; fully-qualified # 🧃 E12.1 beverage box\ - n1F9C9 \; fully-qualified # 🧉 - E12.1 mate\n1F9CA \; fully-qualified - # 🧊 E12.1 ice\n\n# subgroup: dishware\n1F962 - \; fully-qualified # 🥢 E5.0 chopsticks\n1F37D FE0F - \; fully-qualified # 🍽️ E2.0 fork - and knife with plate\n1F37D \; unqual - ified # 🍽 E2.0 fork and knife with plate\n1F374 - \; fully-qualified # 🍴 E2.0 fork and knife\n1 - F944 \; fully-qualified # 🥄 E4 - .0 spoon\n1F52A \; fully-qualified - # 🔪 E2.0 kitchen knife\n1F3FA \; - fully-qualified # 🏺 E2.0 amphora\n\n# Food & Drink subtotal: 123\ - n# Food & Drink subtotal: 123 w/o modifiers\n\n# group: Travel & Places\n - \n# subgroup: place-map\n1F30D \; ful - ly-qualified # 🌍 E2.0 globe showing Europe-Africa\n1F30E - \; fully-qualified # 🌎 E2.0 globe showin - g Americas\n1F30F \; fully-qualified - # 🌏 E2.0 globe showing Asia-Australia\n1F310 - \; fully-qualified # 🌐 E2.0 globe with meridians\n1F - 5FA FE0F \; fully-qualified # 🗺️ - E2.0 world map\n1F5FA \; unqualified - # 🗺 E2.0 world map\n1F5FE - \; fully-qualified # �� E2.0 map of Japan\n1F9ED - \; fully-qualified # 🧭 E11.0 compass\n\n# subgr - oup: place-geographic\n1F3D4 FE0F \; fully - -qualified # 🏔️ E2.0 snow-capped mountain\n1F3D4 - \; unqualified # 🏔 E2.0 snow-capped mounta - in\n26F0 FE0F \; fully-qualified # - ⛰️ E2.0 mountain\n26F0 \; unqual - ified # ⛰ E2.0 mountain\n1F30B - \; fully-qualified # 🌋 E2.0 volcano\n1F5FB - \; fully-qualified # 🗻 E2.0 mount fuji\n1F3D5 FE0F - \; fully-qualified # 🏕️ E2.0 cam - ping\n1F3D5 \; unqualified # - 🏕 E2.0 camping\n1F3D6 FE0F \; fully-qua - lified # 🏖️ E2.0 beach with umbrella\n1F3D6 - \; unqualified # 🏖 E2.0 beach with umbrella\n1F - 3DC FE0F \; fully-qualified # 🏜️ - E2.0 desert\n1F3DC \; unqualified - # �� E2.0 desert\n1F3DD FE0F \; f - ully-qualified # 🏝️ E2.0 desert island\n1F3DD - \; unqualified # 🏝 E2.0 desert island\n1F3DE - FE0F \; fully-qualified # 🏞️ E2.0 - national park\n1F3DE \; unqualified - # 🏞 E2.0 national park\n\n# subgroup: place-building\n1F3DF FE0 - F \; fully-qualified # 🏟️ E2.0 st - adium\n1F3DF \; unqualified # - 🏟 E2.0 stadium\n1F3DB FE0F \; fully-qu - alified # 🏛️ E2.0 classical building\n1F3DB - \; unqualified # 🏛 E2.0 classical building\n1F3 - D7 FE0F \; fully-qualified # 🏗️ E - 2.0 building construction\n1F3D7 \; u - nqualified # 🏗 E2.0 building construction\n1F9F1 - \; fully-qualified # 🧱 E11.0 brick\n1F3D8 FE - 0F \; fully-qualified # 🏘️ E2.0 h - ouses\n1F3D8 \; unqualified # - 🏘 E2.0 houses\n1F3DA FE0F \; fully-qua - lified # 🏚️ E2.0 derelict house\n1F3DA - \; unqualified # 🏚 E2.0 derelict house\n1F3E0 - \; fully-qualified # 🏠 E2.0 house\n1 - F3E1 \; fully-qualified # 🏡 E2 - .0 house with garden\n1F3E2 \; fully- - qualified # 🏢 E2.0 office building\n1F3E3 - \; fully-qualified # 🏣 E2.0 Japanese post office\n1F3E4 - \; fully-qualified # 🏤 E2.0 p - ost office\n1F3E5 \; fully-qualified - # 🏥 E2.0 hospital\n1F3E6 \; fu - lly-qualified # �� E2.0 bank\n1F3E8 - \; fully-qualified # 🏨 E2.0 hotel\n1F3E9 - \; fully-qualified # 🏩 E2.0 love hotel\n1F3EA - \; fully-qualified # 🏪 E2.0 conve - nience store\n1F3EB \; fully-qualifie - d # 🏫 E2.0 school\n1F3EC \; fu - lly-qualified # 🏬 E2.0 department store\n1F3ED - \; fully-qualified # 🏭 E2.0 factory\n1F3EF - \; fully-qualified # 🏯 E2.0 Japanese - castle\n1F3F0 \; fully-qualified - # 🏰 E2.0 castle\n1F492 \; fully-qu - alified # 💒 E2.0 wedding\n1F5FC - \; fully-qualified # 🗼 E2.0 Tokyo tower\n1F5FD - \; fully-qualified # 🗽 E2.0 Statue of Liberty\n\ - n# subgroup: place-religious\n26EA \ - ; fully-qualified # ⛪ E2.0 church\n1F54C - \; fully-qualified # 🕌 E2.0 mosque\n1F6D5 - \; fully-qualified # 🛕 E12.1 hindu temple\n1F - 54D \; fully-qualified # 🕍 E2. - 0 synagogue\n26E9 FE0F \; fully-qualified - # ⛩️ E2.0 shinto shrine\n26E9 - \; unqualified # ⛩ E2.0 shinto shrine\n1F54B - \; fully-qualified # 🕋 E2.0 kaaba\n\n# subgrou - p: place-other\n26F2 \; fully-qualif - ied # ⛲ E2.0 fountain\n26FA \; - fully-qualified # ⛺ E2.0 tent\n1F301 - \; fully-qualified # 🌁 E2.0 foggy\n1F303 - \; fully-qualified # 🌃 E2.0 night with stars\n1F3 - D9 FE0F \; fully-qualified # 🏙️ E - 2.0 cityscape\n1F3D9 \; unqualified - # 🏙 E2.0 cityscape\n1F304 \ - ; fully-qualified # 🌄 E2.0 sunrise over mountains\n1F305 - \; fully-qualified # 🌅 E2.0 sunrise\n1F3 - 06 \; fully-qualified # 🌆 E2.0 - cityscape at dusk\n1F307 \; fully-qu - alified # 🌇 E2.0 sunset\n1F309 - \; fully-qualified # 🌉 E2.0 bridge at night\n2668 FE0F - \; fully-qualified # ♨️ E2.0 hot springs\n26 - 68 \; unqualified # ♨ E2.0 - hot springs\n1F3A0 \; fully-qualifie - d # 🎠 E2.0 carousel horse\n1F3A1 - \; fully-qualified # 🎡 E2.0 ferris wheel\n1F3A2 - \; fully-qualified # 🎢 E2.0 roller coaster\n1F - 488 \; fully-qualified # �� E - 2.0 barber pole\n1F3AA \; fully-quali - fied # 🎪 E2.0 circus tent\n\n# subgroup: transport-ground\n1F682 - \; fully-qualified # 🚂 E2.0 loco - motive\n1F683 \; fully-qualified - # 🚃 E2.0 railway car\n1F684 \; ful - ly-qualified # 🚄 E2.0 high-speed train\n1F685 - \; fully-qualified # 🚅 E2.0 bullet train\n1F686 - \; fully-qualified # 🚆 E2.0 train - \n1F687 \; fully-qualified # 🚇 - E2.0 metro\n1F688 \; fully-qualified - # 🚈 E2.0 light rail\n1F689 \; - fully-qualified # �� E2.0 station\n1F68A - \; fully-qualified # 🚊 E2.0 tram\n1F69D - \; fully-qualified # 🚝 E2.0 monorail\n1F69E - \; fully-qualified # 🚞 E2.0 mo - untain railway\n1F68B \; fully-qualif - ied # 🚋 E2.0 tram car\n1F68C \ - ; fully-qualified # 🚌 E2.0 bus\n1F68D - \; fully-qualified # 🚍 E2.0 oncoming bus\n1F68E - \; fully-qualified # 🚎 E2.0 trolleybus\n1 - F690 \; fully-qualified # 🚐 E2 - .0 minibus\n1F691 \; fully-qualified - # 🚑 E2.0 ambulance\n1F692 \; f - ully-qualified # 🚒 E2.0 fire engine\n1F693 - \; fully-qualified # 🚓 E2.0 police car\n1F694 - \; fully-qualified # 🚔 E2.0 oncoming p - olice car\n1F695 \; fully-qualified - # 🚕 E2.0 taxi\n1F696 \; fully-q - ualified # 🚖 E2.0 oncoming taxi\n1F697 - \; fully-qualified # 🚗 E2.0 automobile\n1F698 - \; fully-qualified # 🚘 E2.0 oncoming autom - obile\n1F699 \; fully-qualified # - 🚙 E2.0 sport utility vehicle\n1F69A - \; fully-qualified # 🚚 E2.0 delivery truck\n1F69B - \; fully-qualified # 🚛 E2.0 articulated lorr - y\n1F69C \; fully-qualified # - 🚜 E2.0 tractor\n1F3CE FE0F \; fully-qua - lified # 🏎️ E2.0 racing car\n1F3CE - \; unqualified # 🏎 E2.0 racing car\n1F3CD FE0F - \; fully-qualified # 🏍️ E2.0 motorcycle\n1 - F3CD \; unqualified # 🏍 E2 - .0 motorcycle\n1F6F5 \; fully-qualifi - ed # 🛵 E4.0 motor scooter\n1F9BD - \; fully-qualified # 🦽 E12.1 manual wheelchair\n1F9BC - \; fully-qualified # 🦼 E12.1 motorized w - heelchair\n1F6FA \; fully-qualified - # 🛺 E12.1 auto rickshaw\n1F6B2 - \; fully-qualified # 🚲 E2.0 bicycle\n1F6F4 - \; fully-qualified # 🛴 E4.0 kick scooter\n1F6F9 - \; fully-qualified # 🛹 E11.0 skatebo - ard\n1F68F \; fully-qualified # - 🚏 E2.0 bus stop\n1F6E3 FE0F \; fully-qu - alified # 🛣️ E2.0 motorway\n1F6E3 - \; unqualified # 🛣 E2.0 motorway\n1F6E4 FE0F - \; fully-qualified # 🛤️ E2.0 railway track\n1 - F6E4 \; unqualified # 🛤 E2 - .0 railway track\n1F6E2 FE0F \; fully-qual - ified # 🛢️ E2.0 oil drum\n1F6E2 - \; unqualified # 🛢 E2.0 oil drum\n26FD - \; fully-qualified # ⛽ E2.0 fuel pump\n1F6A8 - \; fully-qualified # 🚨 E2.0 police - car light\n1F6A5 \; fully-qualified - # 🚥 E2.0 horizontal traffic light\n1F6A6 - \; fully-qualified # 🚦 E2.0 vertical traffic light\n1F6D1 - \; fully-qualified # 🛑 E4.0 s - top sign\n1F6A7 \; fully-qualified - # 🚧 E2.0 construction\n\n# subgroup: transport-water\n2693 - \; fully-qualified # ⚓ E2.0 anchor\n26F5 - \; fully-qualified # ⛵ E2.0 s - ailboat\n1F6F6 \; fully-qualified - # 🛶 E4.0 canoe\n1F6A4 \; fully-qu - alified # 🚤 E2.0 speedboat\n1F6F3 FE0F - \; fully-qualified # 🛳️ E2.0 passenger ship\n1F6F3 - \; unqualified # 🛳 E2.0 passenger sh - ip\n26F4 FE0F \; fully-qualified # - ⛴️ E2.0 ferry\n26F4 \; unqualifi - ed # ⛴ E2.0 ferry\n1F6E5 FE0F \; - fully-qualified # 🛥️ E2.0 motor boat\n1F6E5 - \; unqualified # 🛥 E2.0 motor boat\n1F6A2 - \; fully-qualified # 🚢 E2.0 ship\n - \n# subgroup: transport-air\n2708 FE0F \; - fully-qualified # ✈️ E2.0 airplane\n2708 - \; unqualified # ✈ E2.0 airplane\n1F6E9 FE0F - \; fully-qualified # 🛩️ E2.0 small air - plane\n1F6E9 \; unqualified # - 🛩 E2.0 small airplane\n1F6EB \; f - ully-qualified # �� E2.0 airplane departure\n1F6EC - \; fully-qualified # 🛬 E2.0 airplane arrival\ - n1FA82 \; fully-qualified # 🪂 - E12.1 parachute\n1F4BA \; fully-quali - fied # 💺 E2.0 seat\n1F681 \; f - ully-qualified # 🚁 E2.0 helicopter\n1F69F - \; fully-qualified # 🚟 E2.0 suspension railway\n1F6A0 - \; fully-qualified # 🚠 E2.0 mou - ntain cableway\n1F6A1 \; fully-qualif - ied # 🚡 E2.0 aerial tramway\n1F6F0 FE0F - \; fully-qualified # 🛰️ E2.0 satellite\n1F6F0 - \; unqualified # 🛰 E2.0 satellite\n1F680 - \; fully-qualified # 🚀 E2.0 r - ocket\n1F6F8 \; fully-qualified # - 🛸 E5.0 flying saucer\n\n# subgroup: hotel\n1F6CE FE0F - \; fully-qualified # 🛎️ E2.0 bellhop bell\n1F6CE - \; unqualified # 🛎 E2.0 be - llhop bell\n1F9F3 \; fully-qualified - # 🧳 E11.0 luggage\n\n# subgroup: time\n231B - \; fully-qualified # ⌛ E2.0 hourglass done\n23F3 - \; fully-qualified # ⏳ E2.0 hourgl - ass not done\n231A \; fully-qualifie - d # ⌚ E2.0 watch\n23F0 \; full - y-qualified # ⏰ E2.0 alarm clock\n23F1 FE0F - \; fully-qualified # ⏱️ E2.0 stopwatch\n23F1 - \; unqualified # ⏱ E2.0 stopwatch\n23F - 2 FE0F \; fully-qualified # ⏲️ E2 - .0 timer clock\n23F2 \; unqualified - # ⏲ E2.0 timer clock\n1F570 FE0F - \; fully-qualified # 🕰️ E2.0 mantelpiece clock\n1F570 - \; unqualified # 🕰 E2.0 mantelpiece - clock\n1F55B \; fully-qualified # - 🕛 E2.0 twelve o’clock\n1F567 \; - fully-qualified # 🕧 E2.0 twelve-thirty\n1F550 - \; fully-qualified # 🕐 E2.0 one o’clock\n1F55C - \; fully-qualified # 🕜 E2.0 one - -thirty\n1F551 \; fully-qualified - # 🕑 E2.0 two o’clock\n1F55D \; - fully-qualified # 🕝 E2.0 two-thirty\n1F552 - \; fully-qualified # 🕒 E2.0 three o’clock\n1F55E - \; fully-qualified # 🕞 E2.0 three - -thirty\n1F553 \; fully-qualified - # 🕓 E2.0 four o’clock\n1F55F \; - fully-qualified # 🕟 E2.0 four-thirty\n1F554 - \; fully-qualified # 🕔 E2.0 five o’clock\n1F560 - \; fully-qualified # 🕠 E2.0 five - -thirty\n1F555 \; fully-qualified - # 🕕 E2.0 six o’clock\n1F561 \; - fully-qualified # 🕡 E2.0 six-thirty\n1F556 - \; fully-qualified # 🕖 E2.0 seven o’clock\n1F562 - \; fully-qualified # 🕢 E2.0 seven - -thirty\n1F557 \; fully-qualified - # 🕗 E2.0 eight o’clock\n1F563 \ - ; fully-qualified # 🕣 E2.0 eight-thirty\n1F558 - \; fully-qualified # 🕘 E2.0 nine o’clock\n1F564 - \; fully-qualified # 🕤 E2.0 ni - ne-thirty\n1F559 \; fully-qualified - # 🕙 E2.0 ten o’clock\n1F565 \ - ; fully-qualified # 🕥 E2.0 ten-thirty\n1F55A - \; fully-qualified # 🕚 E2.0 eleven o’clock\n1F566 - \; fully-qualified # 🕦 E2.0 el - even-thirty\n\n# subgroup: sky & weather\n1F311 - \; fully-qualified # 🌑 E2.0 new moon\n1F312 - \; fully-qualified # 🌒 E2.0 waxing crescen - t moon\n1F313 \; fully-qualified - # 🌓 E2.0 first quarter moon\n1F314 - \; fully-qualified # 🌔 E2.0 waxing gibbous moon\n1F315 - \; fully-qualified # 🌕 E2.0 full moon\n1F - 316 \; fully-qualified # 🌖 E2. - 0 waning gibbous moon\n1F317 \; fully - -qualified # 🌗 E2.0 last quarter moon\n1F318 - \; fully-qualified # 🌘 E2.0 waning crescent moon\n1F - 319 \; fully-qualified # 🌙 E2. - 0 crescent moon\n1F31A \; fully-quali - fied # 🌚 E2.0 new moon face\n1F31B - \; fully-qualified # 🌛 E2.0 first quarter moon face\n1F31C - \; fully-qualified # 🌜 E2.0 last - quarter moon face\n1F321 FE0F \; fully-qua - lified # 🌡️ E2.0 thermometer\n1F321 - \; unqualified # 🌡 E2.0 thermometer\n2600 FE0F - \; fully-qualified # ☀️ E2.0 sun\n2600 - \; unqualified # ☀ E2.0 sun\ - n1F31D \; fully-qualified # 🌝 - E2.0 full moon face\n1F31E \; fully-q - ualified # 🌞 E2.0 sun with face\n1FA90 - \; fully-qualified # 🪐 E12.1 ringed planet\n2B50 - \; fully-qualified # ⭐ E2.0 star\n1F31F - \; fully-qualified # 🌟 E2.0 g - lowing star\n1F320 \; fully-qualified - # 🌠 E2.0 shooting star\n1F30C - \; fully-qualified # 🌌 E2.0 milky way\n2601 FE0F - \; fully-qualified # ☁️ E2.0 cloud\n2601 - \; unqualified # ☁ E2.0 cloud\n26C5 - \; fully-qualified # ⛅ E2.0 s - un behind cloud\n26C8 FE0F \; fully-quali - fied # ⛈️ E2.0 cloud with lightning and rain\n26C8 - \; unqualified # ⛈ E2.0 cloud with lightn - ing and rain\n1F324 FE0F \; fully-qualifie - d # 🌤️ E2.0 sun behind small cloud\n1F324 - \; unqualified # 🌤 E2.0 sun behind small cloud\n1 - F325 FE0F \; fully-qualified # 🌥️ - E2.0 sun behind large cloud\n1F325 \ - ; unqualified # 🌥 E2.0 sun behind large cloud\n1F326 FE0F - \; fully-qualified # 🌦️ E2.0 sun behin - d rain cloud\n1F326 \; unqualified - # 🌦 E2.0 sun behind rain cloud\n1F327 FE0F - \; fully-qualified # 🌧️ E2.0 cloud with rain\n1F327 - \; unqualified # 🌧 E2.0 cloud - with rain\n1F328 FE0F \; fully-qualified - # 🌨️ E2.0 cloud with snow\n1F328 - \; unqualified # 🌨 E2.0 cloud with snow\n1F329 FE0F - \; fully-qualified # 🌩️ E2.0 cloud with - lightning\n1F329 \; unqualified - # 🌩 E2.0 cloud with lightning\n1F32A FE0F - \; fully-qualified # 🌪️ E2.0 tornado\n1F32A - \; unqualified # 🌪 E2.0 tornado\n1F32B F - E0F \; fully-qualified # 🌫️ E2.0 - fog\n1F32B \; unqualified # - 🌫 E2.0 fog\n1F32C FE0F \; fully-qualifi - ed # 🌬️ E2.0 wind face\n1F32C - \; unqualified # 🌬 E2.0 wind face\n1F300 - \; fully-qualified # 🌀 E2.0 cyclone\n1F308 - \; fully-qualified # 🌈 E2.0 rainbow\n - 1F302 \; fully-qualified # 🌂 E - 2.0 closed umbrella\n2602 FE0F \; fully-q - ualified # ☂️ E2.0 umbrella\n2602 - \; unqualified # ☂ E2.0 umbrella\n2614 - \; fully-qualified # ☔ E2.0 umbrella with rain dr - ops\n26F1 FE0F \; fully-qualified # - ⛱️ E2.0 umbrella on ground\n26F1 - \; unqualified # ⛱ E2.0 umbrella on ground\n26A1 - \; fully-qualified # ⚡ E2.0 high voltage\n27 - 44 FE0F \; fully-qualified # ❄️ E - 2.0 snowflake\n2744 \; unqualified - # ❄ E2.0 snowflake\n2603 FE0F \; - fully-qualified # ☃️ E2.0 snowman\n2603 - \; unqualified # ☃ E2.0 snowman\n26C4 - \; fully-qualified # ⛄ E2.0 snowman without - snow\n2604 FE0F \; fully-qualified # - ☄️ E2.0 comet\n2604 \; unqualif - ied # ☄ E2.0 comet\n1F525 \ - ; fully-qualified # 🔥 E2.0 fire\n1F4A7 - \; fully-qualified # 💧 E2.0 droplet\n1F30A - \; fully-qualified # 🌊 E2.0 water wave\n\n# T - ravel & Places subtotal: 259\n# Travel & Places subtotal: 259 w/o modifi - ers\n\n# group: Activities\n\n# subgroup: event\n1F383 - \; fully-qualified # 🎃 E2.0 jack-o-lantern\n1F384 - \; fully-qualified # 🎄 E2.0 C - hristmas tree\n1F386 \; fully-qualifi - ed # 🎆 E2.0 fireworks\n1F387 \ - ; fully-qualified # 🎇 E2.0 sparkler\n1F9E8 - \; fully-qualified # 🧨 E11.0 firecracker\n2728 - \; fully-qualified # ✨ E2.0 sparkles\ - n1F388 \; fully-qualified # 🎈 - E2.0 balloon\n1F389 \; fully-qualifie - d # 🎉 E2.0 party popper\n1F38A - \; fully-qualified # 🎊 E2.0 confetti ball\n1F38B - \; fully-qualified # 🎋 E2.0 tanabata tree\n1F38 - D \; fully-qualified # 🎍 E2.0 - pine decoration\n1F38E \; fully-quali - fied # 🎎 E2.0 Japanese dolls\n1F38F - \; fully-qualified # 🎏 E2.0 carp streamer\n1F390 - \; fully-qualified # 🎐 E2.0 wind chime\n1F - 391 \; fully-qualified # 🎑 E2. - 0 moon viewing ceremony\n1F9E7 \; ful - ly-qualified # 🧧 E11.0 red envelope\n1F380 - \; fully-qualified # 🎀 E2.0 ribbon\n1F381 - \; fully-qualified # 🎁 E2.0 wrapped gift\n - 1F397 FE0F \; fully-qualified # 🎗 - ️ E2.0 reminder ribbon\n1F397 \; un - qualified # 🎗 E2.0 reminder ribbon\n1F39F FE0F - \; fully-qualified # 🎟️ E2.0 admission tickets\n1 - F39F \; unqualified # 🎟 E2 - .0 admission tickets\n1F3AB \; fully- - qualified # 🎫 E2.0 ticket\n\n# subgroup: award-medal\n1F396 FE0F - \; fully-qualified # 🎖️ E2.0 milita - ry medal\n1F396 \; unqualified - # 🎖 E2.0 military medal\n1F3C6 \ - ; fully-qualified # 🏆 E2.0 trophy\n1F3C5 - \; fully-qualified # 🏅 E2.0 sports medal\n1F947 - \; fully-qualified # 🥇 E4.0 1st place - medal\n1F948 \; fully-qualified # - 🥈 E4.0 2nd place medal\n1F949 \; - fully-qualified # 🥉 E4.0 3rd place medal\n\n# subgroup: sport\n26BD - \; fully-qualified # ⚽ E2.0 s - occer ball\n26BE \; fully-qualified - # ⚾ E2.0 baseball\n1F94E \; ful - ly-qualified # 🥎 E11.0 softball\n1F3C0 - \; fully-qualified # 🏀 E2.0 basketball\n1F3D0 - \; fully-qualified # 🏐 E2.0 volleyball\n1F - 3C8 \; fully-qualified # �� E - 2.0 american football\n1F3C9 \; fully - -qualified # 🏉 E2.0 rugby football\n1F3BE - \; fully-qualified # 🎾 E2.0 tennis\n1F94F - \; fully-qualified # 🥏 E11.0 flying disc\n1 - F3B3 \; fully-qualified # 🎳 E2 - .0 bowling\n1F3CF \; fully-qualified - # 🏏 E2.0 cricket game\n1F3D1 \ - ; fully-qualified # 🏑 E2.0 field hockey\n1F3D2 - \; fully-qualified # 🏒 E2.0 ice hockey\n1F94D - \; fully-qualified # 🥍 E11.0 lacro - sse\n1F3D3 \; fully-qualified # - 🏓 E2.0 ping pong\n1F3F8 \; fully-q - ualified # 🏸 E2.0 badminton\n1F94A - \; fully-qualified # �� E4.0 boxing glove\n1F94B - \; fully-qualified # 🥋 E4.0 martial arts u - niform\n1F945 \; fully-qualified - # 🥅 E4.0 goal net\n26F3 \; fully- - qualified # ⛳ E2.0 flag in hole\n26F8 FE0F - \; fully-qualified # ⛸️ E2.0 ice skate\n26F8 - \; unqualified # ⛸ E2.0 ice skate\n1F3A - 3 \; fully-qualified # 🎣 E2.0 - fishing pole\n1F93F \; fully-qualifie - d # 🤿 E12.1 diving mask\n1F3BD - \; fully-qualified # 🎽 E2.0 running shirt\n1F3BF - \; fully-qualified # 🎿 E2.0 skis\n1F6F7 - \; fully-qualified # 🛷 E5.0 sled\n1F9 - 4C \; fully-qualified # 🥌 E5.0 - curling stone\n\n# subgroup: game\n1F3AF - \; fully-qualified # 🎯 E2.0 direct hit\n1FA80 - \; fully-qualified # 🪀 E12.1 yo-yo\n1FA81 - \; fully-qualified # 🪁 E12.1 kite\ - n1F3B1 \; fully-qualified # 🎱 - E2.0 pool 8 ball\n1F52E \; fully-qual - ified # 🔮 E2.0 crystal ball\n1F9FF - \; fully-qualified # 🧿 E11.0 nazar amulet\n1F3AE - \; fully-qualified # 🎮 E2.0 video game\n1F5 - 79 FE0F \; fully-qualified # 🕹️ E - 2.0 joystick\n1F579 \; unqualified - # 🕹 E2.0 joystick\n1F3B0 \; - fully-qualified # 🎰 E2.0 slot machine\n1F3B2 - \; fully-qualified # 🎲 E2.0 game die\n1F9E9 - \; fully-qualified # 🧩 E11.0 puzzle pi - ece\n1F9F8 \; fully-qualified # - 🧸 E11.0 teddy bear\n2660 FE0F \; fully - -qualified # ♠️ E2.0 spade suit\n2660 - \; unqualified # ♠ E2.0 spade suit\n2665 FE0F - \; fully-qualified # ♥️ E2.0 heart suit\n - 2665 \; unqualified # ♥ E2 - .0 heart suit\n2666 FE0F \; fully-qualifi - ed # ♦️ E2.0 diamond suit\n2666 - \; unqualified # ♦ E2.0 diamond suit\n2663 FE0F - \; fully-qualified # ♣️ E2.0 club suit\n2663 - \; unqualified # ♣ E2.0 cl - ub suit\n265F FE0F \; fully-qualified - # ♟️ E11.0 chess pawn\n265F \; - unqualified # ♟ E11.0 chess pawn\n1F0CF - \; fully-qualified # 🃏 E2.0 joker\n1F004 - \; fully-qualified # 🀄 E2.0 mahjong red dra - gon\n1F3B4 \; fully-qualified # - 🎴 E2.0 flower playing cards\n\n# subgroup: arts & crafts\n1F3AD - \; fully-qualified # 🎭 E2.0 performin - g arts\n1F5BC FE0F \; fully-qualified - # 🖼️ E2.0 framed picture\n1F5BC - \; unqualified # 🖼 E2.0 framed picture\n1F3A8 - \; fully-qualified # 🎨 E2.0 artist palette\n1F9 - F5 \; fully-qualified # 🧵 E11. - 0 thread\n1F9F6 \; fully-qualified - # 🧶 E11.0 yarn\n\n# Activities subtotal: 90\n# Activities subtotal: - 90 w/o modifiers\n\n# group: Objects\n\n# subgroup: clothing\n1F453 - \; fully-qualified # 👓 E2.0 glasses - \n1F576 FE0F \; fully-qualified # 🕶 - ️ E2.0 sunglasses\n1F576 \; unquali - fied # 🕶 E2.0 sunglasses\n1F97D - \; fully-qualified # 🥽 E11.0 goggles\n1F97C - \; fully-qualified # 🥼 E11.0 lab coat\n1F9BA - \; fully-qualified # 🦺 E12.1 saf - ety vest\n1F454 \; fully-qualified - # 👔 E2.0 necktie\n1F455 \; fully - -qualified # 👕 E2.0 t-shirt\n1F456 - \; fully-qualified # 👖 E2.0 jeans\n1F9E3 - \; fully-qualified # 🧣 E5.0 scarf\n1F9E4 - \; fully-qualified # 🧤 E5.0 gloves\n1F9E - 5 \; fully-qualified # 🧥 E5.0 - coat\n1F9E6 \; fully-qualified # - 🧦 E5.0 socks\n1F457 \; fully-quali - fied # 👗 E2.0 dress\n1F458 \; - fully-qualified # 👘 E2.0 kimono\n1F97B - \; fully-qualified # 🥻 E12.1 sari\n1FA71 - \; fully-qualified # 🩱 E12.1 one-piece swimsuit - \n1FA72 \; fully-qualified # 🩲 - E12.1 briefs\n1FA73 \; fully-qualifi - ed # 🩳 E12.1 shorts\n1F459 \; - fully-qualified # 👙 E2.0 bikini\n1F45A - \; fully-qualified # 👚 E2.0 woman’s clothes\n1F45B - \; fully-qualified # 👛 E2.0 purse\n - 1F45C \; fully-qualified # 👜 E - 2.0 handbag\n1F45D \; fully-qualified - # 👝 E2.0 clutch bag\n1F6CD FE0F \; - fully-qualified # 🛍️ E2.0 shopping bags\n1F6CD - \; unqualified # 🛍 E2.0 shopping bags\n1F39 - 2 \; fully-qualified # 🎒 E2.0 - backpack\n1F45E \; fully-qualified - # 👞 E2.0 man’s shoe\n1F45F \; - fully-qualified # 👟 E2.0 running shoe\n1F97E - \; fully-qualified # 🥾 E11.0 hiking boot\n1F97F - \; fully-qualified # 🥿 E11.0 flat - shoe\n1F460 \; fully-qualified # - 👠 E2.0 high-heeled shoe\n1F461 \; - fully-qualified # 👡 E2.0 woman’s sandal\n1FA70 - \; fully-qualified # 🩰 E12.1 ballet shoes\n1F462 - \; fully-qualified # 👢 E2.0 w - oman’s boot\n1F451 \; fully-qualifi - ed # 👑 E2.0 crown\n1F452 \; fu - lly-qualified # 👒 E2.0 woman’s hat\n1F3A9 - \; fully-qualified # 🎩 E2.0 top hat\n1F393 - \; fully-qualified # 🎓 E2.0 graduation c - ap\n1F9E2 \; fully-qualified # - 🧢 E5.0 billed cap\n26D1 FE0F \; fully- - qualified # ⛑️ E2.0 rescue worker’s helmet\n26D1 - \; unqualified # ⛑ E2.0 rescue worker’s - helmet\n1F4FF \; fully-qualified - # 📿 E2.0 prayer beads\n1F484 \; f - ully-qualified # 💄 E2.0 lipstick\n1F48D - \; fully-qualified # 💍 E2.0 ring\n1F48E - \; fully-qualified # 💎 E2.0 gem stone\n\n# subg - roup: sound\n1F507 \; fully-qualified - # 🔇 E2.0 muted speaker\n1F508 - \; fully-qualified # 🔈 E2.0 speaker low volume\n1F509 - \; fully-qualified # 🔉 E2.0 speaker medium - volume\n1F50A \; fully-qualified - # 🔊 E2.0 speaker high volume\n1F4E2 - \; fully-qualified # 📢 E2.0 loudspeaker\n1F4E3 - \; fully-qualified # 📣 E2.0 megaphone\n1F4EF - \; fully-qualified # 📯 E2.0 post - al horn\n1F514 \; fully-qualified - # 🔔 E2.0 bell\n1F515 \; fully-qua - lified # 🔕 E2.0 bell with slash\n\n# subgroup: music\n1F3BC - \; fully-qualified # 🎼 E2.0 musical s - core\n1F3B5 \; fully-qualified # - 🎵 E2.0 musical note\n1F3B6 \; full - y-qualified # 🎶 E2.0 musical notes\n1F399 FE0F - \; fully-qualified # 🎙️ E2.0 studio microphone\n1F399 - \; unqualified # 🎙 E2.0 s - tudio microphone\n1F39A FE0F \; fully-qual - ified # 🎚️ E2.0 level slider\n1F39A - \; unqualified # 🎚 E2.0 level slider\n1F39B FE0F - \; fully-qualified # 🎛️ E2.0 control kn - obs\n1F39B \; unqualified # - 🎛 E2.0 control knobs\n1F3A4 \; ful - ly-qualified # 🎤 E2.0 microphone\n1F3A7 - \; fully-qualified # 🎧 E2.0 headphone\n1F4FB - \; fully-qualified # 📻 E2.0 radio\n\n# sub - group: musical-instrument\n1F3B7 \; f - ully-qualified # 🎷 E2.0 saxophone\n1F3B8 - \; fully-qualified # 🎸 E2.0 guitar\n1F3B9 - \; fully-qualified # 🎹 E2.0 musical keyboard - \n1F3BA \; fully-qualified # 🎺 - E2.0 trumpet\n1F3BB \; fully-qualifi - ed # 🎻 E2.0 violin\n1FA95 \; f - ully-qualified # 🪕 E12.1 banjo\n1F941 - \; fully-qualified # 🥁 E4.0 drum\n\n# subgroup: phone\n1F4F - 1 \; fully-qualified # 📱 E2.0 - mobile phone\n1F4F2 \; fully-qualifie - d # 📲 E2.0 mobile phone with arrow\n260E FE0F - \; fully-qualified # ☎️ E2.0 telephone\n260E - \; unqualified # ☎ E2.0 telephone\n - 1F4DE \; fully-qualified # 📞 E - 2.0 telephone receiver\n1F4DF \; full - y-qualified # 📟 E2.0 pager\n1F4E0 - \; fully-qualified # 📠 E2.0 fax machine\n\n# subgroup: computer - \n1F50B \; fully-qualified # 🔋 - E2.0 battery\n1F50C \; fully-qualifi - ed # 🔌 E2.0 electric plug\n1F4BB - \; fully-qualified # 💻 E2.0 laptop\n1F5A5 FE0F - \; fully-qualified # 🖥️ E2.0 desktop computer\n1F5 - A5 \; unqualified # 🖥 E2.0 - desktop computer\n1F5A8 FE0F \; fully-qua - lified # 🖨️ E2.0 printer\n1F5A8 - \; unqualified # 🖨 E2.0 printer\n2328 FE0F - \; fully-qualified # ⌨️ E2.0 keyboard\n2328 - \; unqualified # ⌨ E2.0 keyboar - d\n1F5B1 FE0F \; fully-qualified # - 🖱️ E2.0 computer mouse\n1F5B1 \; - unqualified # 🖱 E2.0 computer mouse\n1F5B2 FE0F - \; fully-qualified # 🖲️ E2.0 trackball\n1F5B2 - \; unqualified # 🖲 E2.0 tra - ckball\n1F4BD \; fully-qualified - # 💽 E2.0 computer disk\n1F4BE \; f - ully-qualified # 💾 E2.0 floppy disk\n1F4BF - \; fully-qualified # 💿 E2.0 optical disk\n1F4C0 - \; fully-qualified # 📀 E2.0 dvd\n1F9 - EE \; fully-qualified # 🧮 E11. - 0 abacus\n\n# subgroup: light & video\n1F3A5 - \; fully-qualified # 🎥 E2.0 movie camera\n1F39E FE0F - \; fully-qualified # 🎞️ E2.0 film frame - s\n1F39E \; unqualified # - 🎞 E2.0 film frames\n1F4FD FE0F \; fully - -qualified # 📽️ E2.0 film projector\n1F4FD - \; unqualified # 📽 E2.0 film projector\n1F3AC - \; fully-qualified # 🎬 E2.0 clap - per board\n1F4FA \; fully-qualified - # 📺 E2.0 television\n1F4F7 \; f - ully-qualified # 📷 E2.0 camera\n1F4F8 - \; fully-qualified # 📸 E2.0 camera with flash\n1F4F9 - \; fully-qualified # 📹 E2.0 video ca - mera\n1F4FC \; fully-qualified # - �� E2.0 videocassette\n1F50D \; f - ully-qualified # 🔍 E2.0 magnifying glass tilted left\n1F50E - \; fully-qualified # 🔎 E2.0 magnifyin - g glass tilted right\n1F56F FE0F \; fully- - qualified # 🕯️ E2.0 candle\n1F56F - \; unqualified # 🕯 E2.0 candle\n1F4A1 - \; fully-qualified # 💡 E2.0 light bulb\n1F526 - \; fully-qualified # 🔦 E2.0 flash - light\n1F3EE \; fully-qualified # - 🏮 E2.0 red paper lantern\n1FA94 \ - ; fully-qualified # 🪔 E12.1 diya lamp\n\n# subgroup: book-paper\n1F - 4D4 \; fully-qualified # 📔 E2. - 0 notebook with decorative cover\n1F4D5 - \; fully-qualified # 📕 E2.0 closed book\n1F4D6 - \; fully-qualified # 📖 E2.0 open book\n1F4D7 - \; fully-qualified # 📗 E2.0 gree - n book\n1F4D8 \; fully-qualified - # 📘 E2.0 blue book\n1F4D9 \; fully - -qualified # 📙 E2.0 orange book\n1F4DA - \; fully-qualified # 📚 E2.0 books\n1F4D3 - \; fully-qualified # 📓 E2.0 notebook\n1F4D2 - \; fully-qualified # �� E2.0 led - ger\n1F4C3 \; fully-qualified # - 📃 E2.0 page with curl\n1F4DC \; fu - lly-qualified # 📜 E2.0 scroll\n1F4C4 - \; fully-qualified # 📄 E2.0 page facing up\n1F4F0 - \; fully-qualified # 📰 E2.0 newspaper\n1 - F5DE FE0F \; fully-qualified # 🗞️ - E2.0 rolled-up newspaper\n1F5DE \; u - nqualified # 🗞 E2.0 rolled-up newspaper\n1F4D1 - \; fully-qualified # 📑 E2.0 bookmark tabs\n1F5 - 16 \; fully-qualified # 🔖 E2.0 - bookmark\n1F3F7 FE0F \; fully-qualified - # 🏷️ E2.0 label\n1F3F7 \; unq - ualified # 🏷 E2.0 label\n\n# subgroup: money\n1F4B0 - \; fully-qualified # 💰 E2.0 money bag\n1F - 4B4 \; fully-qualified # 💴 E2. - 0 yen banknote\n1F4B5 \; fully-qualif - ied # 💵 E2.0 dollar banknote\n1F4B6 - \; fully-qualified # 💶 E2.0 euro banknote\n1F4B7 - \; fully-qualified # 💷 E2.0 pound banknote - \n1F4B8 \; fully-qualified # 💸 - E2.0 money with wings\n1F4B3 \; full - y-qualified # 💳 E2.0 credit card\n1F9FE - \; fully-qualified # 🧾 E11.0 receipt\n1F4B9 - \; fully-qualified # 💹 E2.0 chart increasin - g with yen\n1F4B1 \; fully-qualified - # 💱 E2.0 currency exchange\n1F4B2 - \; fully-qualified # 💲 E2.0 heavy dollar sign\n\n# subgroup: ma - il\n2709 FE0F \; fully-qualified # - ✉️ E2.0 envelope\n2709 \; unqual - ified # ✉ E2.0 envelope\n1F4E7 - \; fully-qualified # 📧 E2.0 e-mail\n1F4E8 - \; fully-qualified # 📨 E2.0 incoming envelope\n1F4E - 9 \; fully-qualified # 📩 E2.0 - envelope with arrow\n1F4E4 \; fully-q - ualified # 📤 E2.0 outbox tray\n1F4E5 - \; fully-qualified # 📥 E2.0 inbox tray\n1F4E6 - \; fully-qualified # 📦 E2.0 package\n1F4EB - \; fully-qualified # 📫 E2.0 clo - sed mailbox with raised flag\n1F4EA \ - ; fully-qualified # 📪 E2.0 closed mailbox with lowered flag\n1F4EC - \; fully-qualified # 📬 E2.0 op - en mailbox with raised flag\n1F4ED \; - fully-qualified # 📭 E2.0 open mailbox with lowered flag\n1F4EE - \; fully-qualified # 📮 E2.0 postb - ox\n1F5F3 FE0F \; fully-qualified # - 🗳️ E2.0 ballot box with ballot\n1F5F3 - \; unqualified # 🗳 E2.0 ballot box with ballot\n\n# subgr - oup: writing\n270F FE0F \; fully-qualifie - d # ✏️ E2.0 pencil\n270F \; - unqualified # ✏ E2.0 pencil\n2712 FE0F - \; fully-qualified # ✒️ E2.0 black nib\n2712 - \; unqualified # ✒ E2.0 black nib\n1F58 - B FE0F \; fully-qualified # 🖋️ E2 - .0 fountain pen\n1F58B \; unqualified - # 🖋 E2.0 fountain pen\n1F58A FE0F - \; fully-qualified # 🖊️ E2.0 pen\n1F58A - \; unqualified # 🖊 E2.0 pen\n1F58C FE0F - \; fully-qualified # 🖌️ E2.0 paintbrush\ - n1F58C \; unqualified # 🖌 - E2.0 paintbrush\n1F58D FE0F \; fully-quali - fied # 🖍️ E2.0 crayon\n1F58D - \; unqualified # 🖍 E2.0 crayon\n1F4DD - \; fully-qualified # 📝 E2.0 memo\n\n# subgroup: office - \n1F4BC \; fully-qualified # 💼 - E2.0 briefcase\n1F4C1 \; fully-quali - fied # 📁 E2.0 file folder\n1F4C2 - \; fully-qualified # 📂 E2.0 open file folder\n1F5C2 FE0F - \; fully-qualified # 🗂️ E2.0 card index - dividers\n1F5C2 \; unqualified - # 🗂 E2.0 card index dividers\n1F4C5 - \; fully-qualified # 📅 E2.0 calendar\n1F4C6 - \; fully-qualified # 📆 E2.0 tear-off calendar\n1F - 5D2 FE0F \; fully-qualified # 🗒️ - E2.0 spiral notepad\n1F5D2 \; unquali - fied # 🗒 E2.0 spiral notepad\n1F5D3 FE0F - \; fully-qualified # 🗓️ E2.0 spiral calendar\n1F5D3 - \; unqualified # 🗓 E2.0 spira - l calendar\n1F4C7 \; fully-qualified - # 📇 E2.0 card index\n1F4C8 \; - fully-qualified # 📈 E2.0 chart increasing\n1F4C9 - \; fully-qualified # 📉 E2.0 chart decreasing\n1F - 4CA \; fully-qualified # 📊 E2. - 0 bar chart\n1F4CB \; fully-qualified - # 📋 E2.0 clipboard\n1F4CC \; - fully-qualified # 📌 E2.0 pushpin\n1F4CD - \; fully-qualified # 📍 E2.0 round pushpin\n1F4CE - \; fully-qualified # 📎 E2.0 paperclip\ - n1F587 FE0F \; fully-qualified # 🖇 - ️ E2.0 linked paperclips\n1F587 \; - unqualified # 🖇 E2.0 linked paperclips\n1F4CF - \; fully-qualified # 📏 E2.0 straight ruler\n1F4 - D0 \; fully-qualified # 📐 E2.0 - triangular ruler\n2702 FE0F \; fully-qua - lified # ✂️ E2.0 scissors\n2702 - \; unqualified # ✂ E2.0 scissors\n1F5C3 FE0F - \; fully-qualified # 🗃️ E2.0 card file box\n1F5C - 3 \; unqualified # 🗃 E2.0 - card file box\n1F5C4 FE0F \; fully-qualifi - ed # 🗄️ E2.0 file cabinet\n1F5C4 - \; unqualified # 🗄 E2.0 file cabinet\n1F5D1 FE0F - \; fully-qualified # 🗑️ E2.0 wastebasket\n - 1F5D1 \; unqualified # 🗑 E - 2.0 wastebasket\n\n# subgroup: lock\n1F512 - \; fully-qualified # 🔒 E2.0 locked\n1F513 - \; fully-qualified # 🔓 E2.0 unlocked\n1F50F - \; fully-qualified # 🔏 E2.0 locked - with pen\n1F510 \; fully-qualified - # �� E2.0 locked with key\n1F511 - \; fully-qualified # 🔑 E2.0 key\n1F5DD FE0F - \; fully-qualified # 🗝️ E2.0 old key\n1F5DD - \; unqualified # 🗝 E2.0 old key\n\n# - subgroup: tool\n1F528 \; fully-quali - fied # 🔨 E2.0 hammer\n1FA93 \; - fully-qualified # 🪓 E12.1 axe\n26CF FE0F - \; fully-qualified # ⛏️ E2.0 pick\n26CF - \; unqualified # ⛏ E2.0 pick\n2692 FE0F - \; fully-qualified # ⚒️ E2.0 hammer a - nd pick\n2692 \; unqualified - # ⚒ E2.0 hammer and pick\n1F6E0 FE0F \; - fully-qualified # 🛠️ E2.0 hammer and wrench\n1F6E0 - \; unqualified # �� E2.0 hammer and wr - ench\n1F5E1 FE0F \; fully-qualified # - 🗡️ E2.0 dagger\n1F5E1 \; unquali - fied # 🗡 E2.0 dagger\n2694 FE0F - \; fully-qualified # ⚔️ E2.0 crossed swords\n2694 - \; unqualified # ⚔ E2.0 crossed swords\n - 1F52B \; fully-qualified # 🔫 E - 2.0 pistol\n1F3F9 \; fully-qualified - # 🏹 E2.0 bow and arrow\n1F6E1 FE0F - \; fully-qualified # 🛡️ E2.0 shield\n1F6E1 - \; unqualified # 🛡 E2.0 shield\n1F527 - \; fully-qualified # 🔧 E2.0 wrench\n1F52 - 9 \; fully-qualified # 🔩 E2.0 - nut and bolt\n2699 FE0F \; fully-qualifie - d # ⚙️ E2.0 gear\n2699 \; un - qualified # ⚙ E2.0 gear\n1F5DC FE0F - \; fully-qualified # 🗜️ E2.0 clamp\n1F5DC - \; unqualified # 🗜 E2.0 clamp\n2696 FE0F - \; fully-qualified # ⚖️ E2.0 balance - scale\n2696 \; unqualified # - ⚖ E2.0 balance scale\n1F9AF \; ful - ly-qualified # 🦯 E12.1 probing cane\n1F517 - \; fully-qualified # 🔗 E2.0 link\n26D3 FE0F - \; fully-qualified # ⛓️ E2.0 chains\n26D3 - \; unqualified # ⛓ E2.0 cha - ins\n1F9F0 \; fully-qualified # - 🧰 E11.0 toolbox\n1F9F2 \; fully-qu - alified # 🧲 E11.0 magnet\n\n# subgroup: science\n2697 FE0F - \; fully-qualified # ⚗️ E2.0 alembic\n269 - 7 \; unqualified # ⚗ E2.0 - alembic\n1F9EA \; fully-qualified - # 🧪 E11.0 test tube\n1F9EB \; ful - ly-qualified # 🧫 E11.0 petri dish\n1F9EC - \; fully-qualified # 🧬 E11.0 dna\n1F52C - \; fully-qualified # 🔬 E2.0 microscope\n1F52D - \; fully-qualified # 🔭 E2.0 te - lescope\n1F4E1 \; fully-qualified - # 📡 E2.0 satellite antenna\n\n# subgroup: medical\n1F489 - \; fully-qualified # 💉 E2.0 syringe\n1FA78 - \; fully-qualified # 🩸 E12.1 d - rop of blood\n1F48A \; fully-qualifie - d # 💊 E2.0 pill\n1FA79 \; full - y-qualified # 🩹 E12.1 adhesive bandage\n1FA7A - \; fully-qualified # 🩺 E12.1 stethoscope\n\n# subgr - oup: household\n1F6AA \; fully-qualif - ied # 🚪 E2.0 door\n1F6CF FE0F \; fu - lly-qualified # 🛏️ E2.0 bed\n1F6CF - \; unqualified # 🛏 E2.0 bed\n1F6CB FE0F - \; fully-qualified # 🛋️ E2.0 couch and lamp\n1F6C - B \; unqualified # 🛋 E2.0 - couch and lamp\n1FA91 \; fully-qualif - ied # 🪑 E12.1 chair\n1F6BD \; - fully-qualified # 🚽 E2.0 toilet\n1F6BF - \; fully-qualified # 🚿 E2.0 shower\n1F6C1 - \; fully-qualified # 🛁 E2.0 bathtub\n1FA92 - \; fully-qualified # 🪒 E12.1 razo - r\n1F9F4 \; fully-qualified # - 🧴 E11.0 lotion bottle\n1F9F7 \; fu - lly-qualified # 🧷 E11.0 safety pin\n1F9F9 - \; fully-qualified # 🧹 E11.0 broom\n1F9FA - \; fully-qualified # 🧺 E11.0 basket\n1F9FB - \; fully-qualified # 🧻 E11.0 r - oll of paper\n1F9FC \; fully-qualifie - d # 🧼 E11.0 soap\n1F9FD \; ful - ly-qualified # 🧽 E11.0 sponge\n1F9EF - \; fully-qualified # 🧯 E11.0 fire extinguisher\n1F6D2 - \; fully-qualified # 🛒 E4.0 shopping - cart\n\n# subgroup: other-object\n1F6AC - \; fully-qualified # 🚬 E2.0 cigarette\n26B0 FE0F - \; fully-qualified # ⚰️ E2.0 coffin\n26B0 - \; unqualified # ⚰ E2.0 coffin\ - n26B1 FE0F \; fully-qualified # ⚱ - ️ E2.0 funeral urn\n26B1 \; unqual - ified # ⚱ E2.0 funeral urn\n1F5FF - \; fully-qualified # 🗿 E2.0 moai\n\n# Objects subtotal: 282 - \n# Objects subtotal: 282 w/o modifiers\n\n# group: Symbols\n\n# subgroup - : transport-sign\n1F3E7 \; fully-qual - ified # 🏧 E2.0 ATM sign\n1F6AE - \; fully-qualified # 🚮 E2.0 litter in bin sign\n1F6B0 - \; fully-qualified # 🚰 E2.0 potable water\ - n267F \; fully-qualified # ♿ E - 2.0 wheelchair symbol\n1F6B9 \; fully - -qualified # 🚹 E2.0 men’s room\n1F6BA - \; fully-qualified # 🚺 E2.0 women’s room\n1F6BB - \; fully-qualified # 🚻 E2.0 restroom\ - n1F6BC \; fully-qualified # 🚼 - E2.0 baby symbol\n1F6BE \; fully-qual - ified # 🚾 E2.0 water closet\n1F6C2 - \; fully-qualified # 🛂 E2.0 passport control\n1F6C3 - \; fully-qualified # 🛃 E2.0 customs\n1F6 - C4 \; fully-qualified # 🛄 E2.0 - baggage claim\n1F6C5 \; fully-qualif - ied # 🛅 E2.0 left luggage\n\n# subgroup: warning\n26A0 FE0F - \; fully-qualified # ⚠️ E2.0 warning\n26 - A0 \; unqualified # ⚠ E2.0 - warning\n1F6B8 \; fully-qualified - # 🚸 E2.0 children crossing\n26D4 - \; fully-qualified # ⛔ E2.0 no entry\n1F6AB - \; fully-qualified # 🚫 E2.0 prohibited\n1F6B3 - \; fully-qualified # 🚳 E2.0 no bicyc - les\n1F6AD \; fully-qualified # - 🚭 E2.0 no smoking\n1F6AF \; fully- - qualified # 🚯 E2.0 no littering\n1F6B1 - \; fully-qualified # 🚱 E2.0 non-potable water\n1F6B7 - \; fully-qualified # 🚷 E2.0 no pede - strians\n1F4F5 \; fully-qualified - # 📵 E2.0 no mobile phones\n1F51E - \; fully-qualified # 🔞 E2.0 no one under eighteen\n2622 FE0F - \; fully-qualified # ☢️ E2.0 radioactiv - e\n2622 \; unqualified # ☢ - E2.0 radioactive\n2623 FE0F \; fully-qua - lified # ☣️ E2.0 biohazard\n2623 - \; unqualified # ☣ E2.0 biohazard\n\n# subgroup: arrow\n2B0 - 6 FE0F \; fully-qualified # ⬆️ E2 - .0 up arrow\n2B06 \; unqualified - # ⬆ E2.0 up arrow\n2197 FE0F \; fu - lly-qualified # ↗️ E2.0 up-right arrow\n2197 - \; unqualified # ↗ E2.0 up-right arrow\n27A1 FE - 0F \; fully-qualified # ➡️ E2.0 r - ight arrow\n27A1 \; unqualified - # ➡ E2.0 right arrow\n2198 FE0F \; - fully-qualified # ↘️ E2.0 down-right arrow\n2198 - \; unqualified # ↘ E2.0 down-right arrow\n2 - B07 FE0F \; fully-qualified # ⬇️ - E2.0 down arrow\n2B07 \; unqualified - # ⬇ E2.0 down arrow\n2199 FE0F - \; fully-qualified # ↙️ E2.0 down-left arrow\n2199 - \; unqualified # ↙ E2.0 down-left arrow\ - n2B05 FE0F \; fully-qualified # ⬅ - ️ E2.0 left arrow\n2B05 \; unquali - fied # ⬅ E2.0 left arrow\n2196 FE0F - \; fully-qualified # ↖️ E2.0 up-left arrow\n2196 - \; unqualified # ↖ E2.0 up-left arrow\ - n2195 FE0F \; fully-qualified # ↕ - ️ E2.0 up-down arrow\n2195 \; unqu - alified # ↕ E2.0 up-down arrow\n2194 FE0F - \; fully-qualified # ↔️ E2.0 left-right arrow\n2194 - \; unqualified # ↔ E2.0 left- - right arrow\n21A9 FE0F \; fully-qualified - # ↩️ E2.0 right arrow curving left\n21A9 - \; unqualified # ↩ E2.0 right arrow curving left\n - 21AA FE0F \; fully-qualified # ↪️ - E2.0 left arrow curving right\n21AA - \; unqualified # ↪ E2.0 left arrow curving right\n2934 FE0F - \; fully-qualified # ⤴️ E2.0 right a - rrow curving up\n2934 \; unqualified - # ⤴ E2.0 right arrow curving up\n2935 FE0F - \; fully-qualified # ⤵️ E2.0 right arrow curving down - \n2935 \; unqualified # ⤵ - E2.0 right arrow curving down\n1F503 - \; fully-qualified # 🔃 E2.0 clockwise vertical arrows\n1F504 - \; fully-qualified # 🔄 E2.0 counterc - lockwise arrows button\n1F519 \; full - y-qualified # 🔙 E2.0 BACK arrow\n1F51A - \; fully-qualified # 🔚 E2.0 END arrow\n1F51B - \; fully-qualified # 🔛 E2.0 ON! arrow\n1F51 - C \; fully-qualified # 🔜 E2.0 - SOON arrow\n1F51D \; fully-qualified - # 🔝 E2.0 TOP arrow\n\n# subgroup: religion\n1F6D0 - \; fully-qualified # 🛐 E2.0 place of worship\n2 - 69B FE0F \; fully-qualified # ⚛️ - E2.0 atom symbol\n269B \; unqualifie - d # ⚛ E2.0 atom symbol\n1F549 FE0F - \; fully-qualified # 🕉️ E2.0 om\n1F549 - \; unqualified # 🕉 E2.0 om\n2721 FE0F - \; fully-qualified # ✡️ E2.0 star of David\n - 2721 \; unqualified # ✡ E2 - .0 star of David\n2638 FE0F \; fully-qual - ified # ☸️ E2.0 wheel of dharma\n2638 - \; unqualified # ☸ E2.0 wheel of dharma\n262F FE0F - \; fully-qualified # ☯️ E2.0 yin yan - g\n262F \; unqualified # ☯ - E2.0 yin yang\n271D FE0F \; fully-qualif - ied # ✝️ E2.0 latin cross\n271D - \; unqualified # ✝ E2.0 latin cross\n2626 FE0F - \; fully-qualified # ☦️ E2.0 orthodox cross\n2 - 626 \; unqualified # ☦ E2. - 0 orthodox cross\n262A FE0F \; fully-qual - ified # ☪️ E2.0 star and crescent\n262A - \; unqualified # ☪ E2.0 star and crescent\n262E FE0F - \; fully-qualified # ☮️ E2.0 pea - ce symbol\n262E \; unqualified - # ☮ E2.0 peace symbol\n1F54E \; - fully-qualified # 🕎 E2.0 menorah\n1F52F - \; fully-qualified # 🔯 E2.0 dotted six-pointed star\n\n# - subgroup: zodiac\n2648 \; fully-qual - ified # ♈ E2.0 Aries\n2649 \; - fully-qualified # ♉ E2.0 Taurus\n264A - \; fully-qualified # ♊ E2.0 Gemini\n264B - \; fully-qualified # ♋ E2.0 Cancer\n264C - \; fully-qualified # ♌ E2.0 Leo\n264D - \; fully-qualified # ♍ E2.0 Vi - rgo\n264E \; fully-qualified # - ♎ E2.0 Libra\n264F \; fully-qualif - ied # ♏ E2.0 Scorpio\n2650 \; - fully-qualified # ♐ E2.0 Sagittarius\n2651 - \; fully-qualified # ♑ E2.0 Capricorn\n2652 - \; fully-qualified # ♒ E2.0 Aquarius\n265 - 3 \; fully-qualified # ♓ E2.0 - Pisces\n26CE \; fully-qualified - # ⛎ E2.0 Ophiuchus\n\n# subgroup: av-symbol\n1F500 - \; fully-qualified # 🔀 E2.0 shuffle tracks button\n - 1F501 \; fully-qualified # 🔁 E - 2.0 repeat button\n1F502 \; fully-qua - lified # 🔂 E2.0 repeat single button\n25B6 FE0F - \; fully-qualified # ▶️ E2.0 play button\n25B6 - \; unqualified # ▶ E2.0 play bu - tton\n23E9 \; fully-qualified # - ⏩ E2.0 fast-forward button\n23ED FE0F \ - ; fully-qualified # ⏭️ E2.0 next track button\n23ED - \; unqualified # ⏭ E2.0 next track butto - n\n23EF FE0F \; fully-qualified # ⏯ - ️ E2.0 play or pause button\n23EF - \; unqualified # ⏯ E2.0 play or pause button\n25C0 FE0F - \; fully-qualified # ◀️ E2.0 reverse butt - on\n25C0 \; unqualified # - ◀ E2.0 reverse button\n23EA \; ful - ly-qualified # ⏪ E2.0 fast reverse button\n23EE FE0F - \; fully-qualified # ⏮️ E2.0 last track button\n - 23EE \; unqualified # ⏮ E2 - .0 last track button\n1F53C \; fully- - qualified # 🔼 E2.0 upwards button\n23EB - \; fully-qualified # ⏫ E2.0 fast up button\n1F53D - \; fully-qualified # �� E2.0 downwar - ds button\n23EC \; fully-qualified - # ⏬ E2.0 fast down button\n23F8 FE0F - \; fully-qualified # ⏸️ E2.0 pause button\n23F8 - \; unqualified # ⏸ E2.0 pause button\n23F9 - FE0F \; fully-qualified # ⏹️ E2.0 - stop button\n23F9 \; unqualified - # ⏹ E2.0 stop button\n23FA FE0F \ - ; fully-qualified # ⏺️ E2.0 record button\n23FA - \; unqualified # ⏺ E2.0 record button\n23CF - FE0F \; fully-qualified # ⏏️ E2.0 - eject button\n23CF \; unqualified - # ⏏ E2.0 eject button\n1F3A6 - \; fully-qualified # 🎦 E2.0 cinema\n1F505 - \; fully-qualified # 🔅 E2.0 dim button\n1F506 - \; fully-qualified # 🔆 E2.0 bright but - ton\n1F4F6 \; fully-qualified # - 📶 E2.0 antenna bars\n1F4F3 \; full - y-qualified # 📳 E2.0 vibration mode\n1F4F4 - \; fully-qualified # 📴 E2.0 mobile phone off\n\n# subg - roup: gender\n2640 FE0F \; fully-qualifie - d # ♀️ E4.0 female sign\n2640 - \; unqualified # ♀ E4.0 female sign\n2642 FE0F - \; fully-qualified # ♂️ E4.0 male sign\n2642 - \; unqualified # ♂ E4.0 male - sign\n\n# subgroup: other-symbol\n2695 FE0F - \; fully-qualified # ⚕️ E4.0 medical symbol\n2695 - \; unqualified # ⚕ E4.0 medical symbol\ - n267E FE0F \; fully-qualified # ♾ - ️ E11.0 infinity\n267E \; unqualif - ied # ♾ E11.0 infinity\n267B FE0F - \; fully-qualified # ♻️ E2.0 recycling symbol\n267B - \; unqualified # ♻ E2.0 recycling sym - bol\n269C FE0F \; fully-qualified # - ⚜️ E2.0 fleur-de-lis\n269C \; un - qualified # ⚜ E2.0 fleur-de-lis\n1F531 - \; fully-qualified # 🔱 E2.0 trident emblem\n1F4DB - \; fully-qualified # 📛 E2.0 name ba - dge\n1F530 \; fully-qualified # - 🔰 E2.0 Japanese symbol for beginner\n2B55 - \; fully-qualified # ⭕ E2.0 hollow red circle\n2705 - \; fully-qualified # ✅ E2.0 check mar - k button\n2611 FE0F \; fully-qualified - # ☑️ E2.0 check box with check\n2611 - \; unqualified # ☑ E2.0 check box with check\n2714 FE0F - \; fully-qualified # ✔️ E2.0 check - mark\n2714 \; unqualified # - ✔ E2.0 check mark\n2716 FE0F \; fully- - qualified # ✖️ E2.0 multiplication sign\n2716 - \; unqualified # ✖ E2.0 multiplication sign\n2 - 74C \; fully-qualified # ❌ E2. - 0 cross mark\n274E \; fully-qualifie - d # ❎ E2.0 cross mark button\n2795 - \; fully-qualified # ➕ E2.0 plus sign\n2796 - \; fully-qualified # ➖ E2.0 minus sign\n2797 - \; fully-qualified # ➗ E2.0 divisi - on sign\n27B0 \; fully-qualified - # ➰ E2.0 curly loop\n27BF \; full - y-qualified # ➿ E2.0 double curly loop\n303D FE0F - \; fully-qualified # 〽️ E2.0 part alternation mark\ - n303D \; unqualified # 〽 E - 2.0 part alternation mark\n2733 FE0F \; f - ully-qualified # ✳️ E2.0 eight-spoked asterisk\n2733 - \; unqualified # ✳ E2.0 eight-spoked as - terisk\n2734 FE0F \; fully-qualified - # ✴️ E2.0 eight-pointed star\n2734 - \; unqualified # ✴ E2.0 eight-pointed star\n2747 FE0F - \; fully-qualified # ❇️ E2.0 sparkle\n27 - 47 \; unqualified # ❇ E2.0 - sparkle\n203C FE0F \; fully-qualified - # ‼️ E2.0 double exclamation mark\n203C - \; unqualified # ‼ E2.0 double exclamation mark\n2049 - FE0F \; fully-qualified # ⁉️ E2.0 - exclamation question mark\n2049 \; - unqualified # ⁉ E2.0 exclamation question mark\n2753 - \; fully-qualified # ❓ E2.0 question mark - \n2754 \; fully-qualified # ❔ - E2.0 white question mark\n2755 \; fu - lly-qualified # ❕ E2.0 white exclamation mark\n2757 - \; fully-qualified # ❗ E2.0 exclamation mark\n - 3030 FE0F \; fully-qualified # 〰️ - E2.0 wavy dash\n3030 \; unqualified - # 〰 E2.0 wavy dash\n00A9 FE0F - \; fully-qualified # ©️ E2.0 copyright\n00A9 - \; unqualified # © E2.0 copyright\n00AE FE0F - \; fully-qualified # ®️ E2.0 registere - d\n00AE \; unqualified # ® - E2.0 registered\n2122 FE0F \; fully-quali - fied # ™️ E2.0 trade mark\n2122 - \; unqualified # ™ E2.0 trade mark\n\n# subgroup: keycap\n00 - 23 FE0F 20E3 \; fully-qualified # #️⃣ - E0.0 keycap: #\n0023 20E3 \; unqualified - # #⃣ E0.0 keycap: #\n002A FE0F 20E3 - \; fully-qualified # *️⃣ E0.0 keycap: *\n002A 20E3 - \; unqualified # *⃣ E0.0 keycap: *\n0030 FE0F - 20E3 \; fully-qualified # 0️⃣ E0.0 key - cap: 0\n0030 20E3 \; unqualified - # 0⃣ E0.0 keycap: 0\n0031 FE0F 20E3 \; fully - -qualified # 1️⃣ E0.0 keycap: 1\n0031 20E3 - \; unqualified # 1⃣ E0.0 keycap: 1\n0032 FE0F 20E3 - \; fully-qualified # 2️⃣ E0.0 keycap: 2\n - 0032 20E3 \; unqualified # 2⃣ E - 0.0 keycap: 2\n0033 FE0F 20E3 \; fully-qualifi - ed # 3️⃣ E0.0 keycap: 3\n0033 20E3 - \; unqualified # 3⃣ E0.0 keycap: 3\n0034 FE0F 20E3 - \; fully-qualified # 4️⃣ E0.0 keycap: 4\n0034 20E - 3 \; unqualified # 4⃣ E0.0 keyc - ap: 4\n0035 FE0F 20E3 \; fully-qualified # - 5️⃣ E0.0 keycap: 5\n0035 20E3 \; unq - ualified # 5⃣ E0.0 keycap: 5\n0036 FE0F 20E3 - \; fully-qualified # 6️⃣ E0.0 keycap: 6\n0036 20E3 - \; unqualified # 6⃣ E0.0 keycap: 6\n0 - 037 FE0F 20E3 \; fully-qualified # 7️⃣ - E0.0 keycap: 7\n0037 20E3 \; unqualified - # 7⃣ E0.0 keycap: 7\n0038 FE0F 20E3 - \; fully-qualified # 8️⃣ E0.0 keycap: 8\n0038 20E3 - \; unqualified # 8⃣ E0.0 keycap: 8\n0039 FE0F - 20E3 \; fully-qualified # 9️⃣ E0.0 ke - ycap: 9\n0039 20E3 \; unqualified - # 9⃣ E0.0 keycap: 9\n1F51F \; full - y-qualified # 🔟 E2.0 keycap: 10\n\n# subgroup: alphanum\n1F520 - \; fully-qualified # 🔠 E2.0 input - latin uppercase\n1F521 \; fully-quali - fied # 🔡 E2.0 input latin lowercase\n1F522 - \; fully-qualified # 🔢 E2.0 input numbers\n1F523 - \; fully-qualified # 🔣 E2.0 input s - ymbols\n1F524 \; fully-qualified - # 🔤 E2.0 input latin letters\n1F170 FE0F - \; fully-qualified # 🅰️ E2.0 A button (blood type)\n1F170 - \; unqualified # 🅰 E2.0 A butto - n (blood type)\n1F18E \; fully-qualif - ied # 🆎 E2.0 AB button (blood type)\n1F171 FE0F - \; fully-qualified # 🅱️ E2.0 B button (blood type)\n - 1F171 \; unqualified # 🅱 E - 2.0 B button (blood type)\n1F191 \; f - ully-qualified # 🆑 E2.0 CL button\n1F192 - \; fully-qualified # 🆒 E2.0 COOL button\n1F193 - \; fully-qualified # 🆓 E2.0 FREE button - \n2139 FE0F \; fully-qualified # ℹ - ️ E2.0 information\n2139 \; unqual - ified # ℹ E2.0 information\n1F194 - \; fully-qualified # 🆔 E2.0 ID button\n24C2 FE0F - \; fully-qualified # Ⓜ️ E2.0 circled M\n24C2 - \; unqualified # Ⓜ E2.0 c - ircled M\n1F195 \; fully-qualified - # 🆕 E2.0 NEW button\n1F196 \; fu - lly-qualified # 🆖 E2.0 NG button\n1F17E FE0F - \; fully-qualified # 🅾️ E2.0 O button (blood type)\n1F1 - 7E \; unqualified # 🅾 E2.0 - O button (blood type)\n1F197 \; full - y-qualified # 🆗 E2.0 OK button\n1F17F FE0F - \; fully-qualified # 🅿️ E2.0 P button\n1F17F - \; unqualified # 🅿 E2.0 P button\n1F19 - 8 \; fully-qualified # 🆘 E2.0 - SOS button\n1F199 \; fully-qualified - # 🆙 E2.0 UP! button\n1F19A \; - fully-qualified # 🆚 E2.0 VS button\n1F201 - \; fully-qualified # 🈁 E2.0 Japanese “here” button\ - n1F202 FE0F \; fully-qualified # 🈂 - ️ E2.0 Japanese “service charge” button\n1F202 - \; unqualified # 🈂 E2.0 Japanese “service cha - rge” button\n1F237 FE0F \; fully-qualifi - ed # 🈷️ E2.0 Japanese “monthly amount” button\n1F237 - \; unqualified # �� E2.0 Japanese - “monthly amount” button\n1F236 \ - ; fully-qualified # 🈶 E2.0 Japanese “not free of charge” button - \n1F22F \; fully-qualified # 🈯 - E2.0 Japanese “reserved” button\n1F250 - \; fully-qualified # 🉐 E2.0 Japanese “bargain” button\n1 - F239 \; fully-qualified # 🈹 E2 - .0 Japanese “discount” button\n1F21A - \; fully-qualified # 🈚 E2.0 Japanese “free of charge” butto - n\n1F232 \; fully-qualified # - 🈲 E2.0 Japanese “prohibited” button\n1F251 - \; fully-qualified # 🉑 E2.0 Japanese “acceptable” - button\n1F238 \; fully-qualified - # 🈸 E2.0 Japanese “application” button\n1F234 - \; fully-qualified # 🈴 E2.0 Japanese “passing gra - de” button\n1F233 \; fully-qualifie - d # 🈳 E2.0 Japanese “vacancy” button\n3297 FE0F - \; fully-qualified # ㊗️ E2.0 Japanese “congrat - ulations” button\n3297 \; unqualif - ied # ㊗ E2.0 Japanese “congratulations” button\n3299 FE0F - \; fully-qualified # ㊙️ E2.0 Japane - se “secret” button\n3299 \; unqu - alified # ㊙ E2.0 Japanese “secret” button\n1F23A - \; fully-qualified # 🈺 E2.0 Japanese “o - pen for business” button\n1F235 \; - fully-qualified # 🈵 E2.0 Japanese “no vacancy” button\n\n# subg - roup: geometric\n1F534 \; fully-quali - fied # 🔴 E2.0 red circle\n1F7E0 - \; fully-qualified # 🟠 E12.1 orange circle\n1F7E1 - \; fully-qualified # 🟡 E12.1 yellow circle\n1 - F7E2 \; fully-qualified # 🟢 E1 - 2.1 green circle\n1F535 \; fully-qual - ified # 🔵 E2.0 blue circle\n1F7E3 - \; fully-qualified # 🟣 E12.1 purple circle\n1F7E4 - \; fully-qualified # 🟤 E12.1 brown circle\n - 26AB \; fully-qualified # ⚫ E2 - .0 black circle\n26AA \; fully-quali - fied # ⚪ E2.0 white circle\n1F7E5 - \; fully-qualified # 🟥 E12.1 red square\n1F7E7 - \; fully-qualified # 🟧 E12.1 orange square\n1F7 - E8 \; fully-qualified # 🟨 E12. - 1 yellow square\n1F7E9 \; fully-quali - fied # 🟩 E12.1 green square\n1F7E6 - \; fully-qualified # 🟦 E12.1 blue square\n1F7EA - \; fully-qualified # 🟪 E12.1 purple square\n - 1F7EB \; fully-qualified # 🟫 E - 12.1 brown square\n2B1B \; fully-qua - lified # ⬛ E2.0 black large square\n2B1C - \; fully-qualified # ⬜ E2.0 white large square\n25FC FE0F - \; fully-qualified # ◼️ E2.0 bla - ck medium square\n25FC \; unqualifie - d # ◼ E2.0 black medium square\n25FB FE0F - \; fully-qualified # ◻️ E2.0 white medium square\n25FB - \; unqualified # ◻ E2.0 wh - ite medium square\n25FE \; fully-qua - lified # ◾ E2.0 black medium-small square\n25FD - \; fully-qualified # ◽ E2.0 white medium-small squ - are\n25AA FE0F \; fully-qualified # - ▪️ E2.0 black small square\n25AA - \; unqualified # ▪ E2.0 black small square\n25AB FE0F - \; fully-qualified # ▫️ E2.0 white small s - quare\n25AB \; unqualified # - ▫ E2.0 white small square\n1F536 \ - ; fully-qualified # 🔶 E2.0 large orange diamond\n1F537 - \; fully-qualified # 🔷 E2.0 large blue dia - mond\n1F538 \; fully-qualified # - �� E2.0 small orange diamond\n1F539 - \; fully-qualified # 🔹 E2.0 small blue diamond\n1F53A - \; fully-qualified # 🔺 E2.0 red triangle - pointed up\n1F53B \; fully-qualified - # 🔻 E2.0 red triangle pointed down\n1F4A0 - \; fully-qualified # 💠 E2.0 diamond with a dot\n1F518 - \; fully-qualified # 🔘 E2.0 ra - dio button\n1F533 \; fully-qualified - # 🔳 E2.0 white square button\n1F532 - \; fully-qualified # 🔲 E2.0 black square button\n\n# Symbols - subtotal: 297\n# Symbols subtotal: 297 w/o modifiers\n\n# group: Flags\n - \n# subgroup: flag\n1F3C1 \; fully-qu - alified # 🏁 E2.0 chequered flag\n1F6A9 - \; fully-qualified # 🚩 E2.0 triangular flag\n1F38C - \; fully-qualified # 🎌 E2.0 crossed f - lags\n1F3F4 \; fully-qualified # - 🏴 E2.0 black flag\n1F3F3 FE0F \; fully- - qualified # 🏳️ E2.0 white flag\n1F3F3 - \; unqualified # 🏳 E2.0 white flag\n1F3F3 FE0F 200D 1 - F308 \; fully-qualified # ��️‍🌈 E4.0 r - ainbow flag\n1F3F3 200D 1F308 \; unqualified - # 🏳‍🌈 E4.0 rainbow flag\n1F3F4 200D 2620 FE0F - \; fully-qualified # 🏴‍☠️ E11.0 pirate flag\n1F3F4 200 - D 2620 \; minimally-qualified # 🏴‍☠ E11. - 0 pirate flag\n\n# subgroup: country-flag\n1F1E6 1F1E8 - \; fully-qualified # 🇦🇨 E2.0 flag: Ascension Island\ - n1F1E6 1F1E9 \; fully-qualified # 🇦 - 🇩 E2.0 flag: Andorra\n1F1E6 1F1EA \; ful - ly-qualified # 🇦🇪 E2.0 flag: United Arab Emirates\n1F1E6 1F1EB - \; fully-qualified # 🇦🇫 E2.0 flag: - Afghanistan\n1F1E6 1F1EC \; fully-qualifie - d # 🇦🇬 E2.0 flag: Antigua & Barbuda\n1F1E6 1F1EE - \; fully-qualified # 🇦🇮 E2.0 flag: Anguilla\n1F1 - E6 1F1F1 \; fully-qualified # 🇦🇱 - E2.0 flag: Albania\n1F1E6 1F1F2 \; fully-qu - alified # 🇦🇲 E2.0 flag: Armenia\n1F1E6 1F1F4 - \; fully-qualified # 🇦🇴 E2.0 flag: Angola\n1F1E6 1F1 - F6 \; fully-qualified # 🇦�� E2.0 - flag: Antarctica\n1F1E6 1F1F7 \; fully-qua - lified # 🇦🇷 E2.0 flag: Argentina\n1F1E6 1F1F8 - \; fully-qualified # 🇦🇸 E2.0 flag: American Samoa\n - 1F1E6 1F1F9 \; fully-qualified # 🇦 - 🇹 E2.0 flag: Austria\n1F1E6 1F1FA \; ful - ly-qualified # 🇦🇺 E2.0 flag: Australia\n1F1E6 1F1FC - \; fully-qualified # 🇦🇼 E2.0 flag: Aruba\n1F1 - E6 1F1FD \; fully-qualified # 🇦🇽 - E2.0 flag: Åland Islands\n1F1E6 1F1FF \; f - ully-qualified # 🇦🇿 E2.0 flag: Azerbaijan\n1F1E7 1F1E6 - \; fully-qualified # 🇧🇦 E2.0 flag: Bosnia - & Herzegovina\n1F1E7 1F1E7 \; fully-qualifi - ed # 🇧🇧 E2.0 flag: Barbados\n1F1E7 1F1E9 - \; fully-qualified # 🇧🇩 E2.0 flag: Bangladesh\n1F1E7 1F1 - EA \; fully-qualified # 🇧🇪 E2.0 f - lag: Belgium\n1F1E7 1F1EB \; fully-qualifie - d # 🇧🇫 E2.0 flag: Burkina Faso\n1F1E7 1F1EC - \; fully-qualified # 🇧🇬 E2.0 flag: Bulgaria\n1F1E7 1F - 1ED \; fully-qualified # 🇧🇭 E2.0 - flag: Bahrain\n1F1E7 1F1EE \; fully-qualifi - ed # 🇧🇮 E2.0 flag: Burundi\n1F1E7 1F1EF - \; fully-qualified # 🇧🇯 E2.0 flag: Benin\n1F1E7 1F1F1 - \; fully-qualified # 🇧🇱 E2.0 flag: S - t. Barthélemy\n1F1E7 1F1F2 \; fully-qualif - ied # 🇧🇲 E2.0 flag: Bermuda\n1F1E7 1F1F3 - \; fully-qualified # 🇧🇳 E2.0 flag: Brunei\n1F1E7 1F1F4 - \; fully-qualified # 🇧🇴 E2.0 flag: - Bolivia\n1F1E7 1F1F6 \; fully-qualified - # 🇧🇶 E2.0 flag: Caribbean Netherlands\n1F1E7 1F1F7 - \; fully-qualified # 🇧🇷 E2.0 flag: Brazil\n1F1E7 - 1F1F8 \; fully-qualified # 🇧🇸 E2 - .0 flag: Bahamas\n1F1E7 1F1F9 \; fully-qual - ified # 🇧🇹 E2.0 flag: Bhutan\n1F1E7 1F1FB - \; fully-qualified # 🇧🇻 E2.0 flag: Bouvet Island\n1F1E7 - 1F1FC \; fully-qualified # 🇧🇼 E2 - .0 flag: Botswana\n1F1E7 1F1FE \; fully-qua - lified # 🇧🇾 E2.0 flag: Belarus\n1F1E7 1F1FF - \; fully-qualified # 🇧🇿 E2.0 flag: Belize\n1F1E8 1F1E - 6 \; fully-qualified # 🇨🇦 E2.0 fl - ag: Canada\n1F1E8 1F1E8 \; fully-qualified - # 🇨🇨 E2.0 flag: Cocos (Keeling) Islands\n1F1E8 1F1E9 - \; fully-qualified # 🇨🇩 E2.0 flag: Congo - K - inshasa\n1F1E8 1F1EB \; fully-qualified - # 🇨🇫 E2.0 flag: Central African Republic\n1F1E8 1F1EC - \; fully-qualified # 🇨🇬 E2.0 flag: Congo - Bra - zzaville\n1F1E8 1F1ED \; fully-qualified - # 🇨🇭 E2.0 flag: Switzerland\n1F1E8 1F1EE - \; fully-qualified # 🇨🇮 E2.0 flag: Côte d’Ivoire\n1F1E8 - 1F1F0 \; fully-qualified # 🇨🇰 E2 - .0 flag: Cook Islands\n1F1E8 1F1F1 \; fully - -qualified # 🇨🇱 E2.0 flag: Chile\n1F1E8 1F1F2 - \; fully-qualified # 🇨🇲 E2.0 flag: Cameroon\n1F1E8 - 1F1F3 \; fully-qualified # 🇨🇳 E2. - 0 flag: China\n1F1E8 1F1F4 \; fully-qualifi - ed # 🇨🇴 E2.0 flag: Colombia\n1F1E8 1F1F5 - \; fully-qualified # 🇨🇵 E2.0 flag: Clipperton Island\n1F - 1E8 1F1F7 \; fully-qualified # 🇨🇷 - E2.0 flag: Costa Rica\n1F1E8 1F1FA \; full - y-qualified # 🇨🇺 E2.0 flag: Cuba\n1F1E8 1F1FB - \; fully-qualified # 🇨🇻 E2.0 flag: Cape Verde\n1F1E - 8 1F1FC \; fully-qualified # 🇨🇼 E - 2.0 flag: Curaçao\n1F1E8 1F1FD \; fully-qu - alified # 🇨🇽 E2.0 flag: Christmas Island\n1F1E8 1F1FE - \; fully-qualified # 🇨🇾 E2.0 flag: Cyprus\n - 1F1E8 1F1FF \; fully-qualified # 🇨 - 🇿 E2.0 flag: Czechia\n1F1E9 1F1EA \; ful - ly-qualified # 🇩🇪 E2.0 flag: Germany\n1F1E9 1F1EC - \; fully-qualified # 🇩🇬 E2.0 flag: Diego Garcia - \n1F1E9 1F1EF \; fully-qualified # 🇩 - 🇯 E2.0 flag: Djibouti\n1F1E9 1F1F0 \; fu - lly-qualified # 🇩🇰 E2.0 flag: Denmark\n1F1E9 1F1F2 - \; fully-qualified # 🇩🇲 E2.0 flag: Dominica\n1 - F1E9 1F1F4 \; fully-qualified # 🇩 - 🇴 E2.0 flag: Dominican Republic\n1F1E9 1F1FF - \; fully-qualified # 🇩🇿 E2.0 flag: Algeria\n1F1EA 1F1E6 - \; fully-qualified # 🇪🇦 E2.0 flag: C - euta & Melilla\n1F1EA 1F1E8 \; fully-qualif - ied # 🇪🇨 E2.0 flag: Ecuador\n1F1EA 1F1EA - \; fully-qualified # 🇪🇪 E2.0 flag: Estonia\n1F1EA 1F1EC - \; fully-qualified # 🇪🇬 E2.0 flag - : Egypt\n1F1EA 1F1ED \; fully-qualified - # 🇪🇭 E2.0 flag: Western Sahara\n1F1EA 1F1F7 - \; fully-qualified # 🇪🇷 E2.0 flag: Eritrea\n1F1EA 1F1F8 - \; fully-qualified # 🇪🇸 E2.0 flag - : Spain\n1F1EA 1F1F9 \; fully-qualified - # 🇪🇹 E2.0 flag: Ethiopia\n1F1EA 1F1FA - \; fully-qualified # 🇪🇺 E2.0 flag: European Union\n1F1EB 1F1EE - \; fully-qualified # 🇫🇮 E2.0 fla - g: Finland\n1F1EB 1F1EF \; fully-qualified - # 🇫🇯 E2.0 flag: Fiji\n1F1EB 1F1F0 - \; fully-qualified # 🇫🇰 E2.0 flag: Falkland Islands\n1F1EB 1F1F - 2 \; fully-qualified # 🇫🇲 E2.0 fl - ag: Micronesia\n1F1EB 1F1F4 \; fully-qualif - ied # 🇫🇴 E2.0 flag: Faroe Islands\n1F1EB 1F1F7 - \; fully-qualified # 🇫🇷 E2.0 flag: France\n1F1EC 1 - F1E6 \; fully-qualified # 🇬🇦 E2.0 - flag: Gabon\n1F1EC 1F1E7 \; fully-qualifie - d # 🇬🇧 E2.0 flag: United Kingdom\n1F1EC 1F1E9 - \; fully-qualified # 🇬🇩 E2.0 flag: Grenada\n1F1EC 1 - F1EA \; fully-qualified # 🇬🇪 E2.0 - flag: Georgia\n1F1EC 1F1EB \; fully-qualif - ied # 🇬🇫 E2.0 flag: French Guiana\n1F1EC 1F1EC - \; fully-qualified # 🇬🇬 E2.0 flag: Guernsey\n1F1EC - 1F1ED \; fully-qualified # 🇬🇭 E2 - .0 flag: Ghana\n1F1EC 1F1EE \; fully-qualif - ied # 🇬🇮 E2.0 flag: Gibraltar\n1F1EC 1F1F1 - \; fully-qualified # 🇬🇱 E2.0 flag: Greenland\n1F1EC 1F - 1F2 \; fully-qualified # 🇬🇲 E2.0 - flag: Gambia\n1F1EC 1F1F3 \; fully-qualifie - d # 🇬🇳 E2.0 flag: Guinea\n1F1EC 1F1F5 - \; fully-qualified # 🇬🇵 E2.0 flag: Guadeloupe\n1F1EC 1F1F6 - \; fully-qualified # 🇬🇶 E2.0 flag - : Equatorial Guinea\n1F1EC 1F1F7 \; fully-q - ualified # 🇬🇷 E2.0 flag: Greece\n1F1EC 1F1F8 - \; fully-qualified # 🇬🇸 E2.0 flag: South Georgia & S - outh Sandwich Islands\n1F1EC 1F1F9 \; fully - -qualified # 🇬🇹 E2.0 flag: Guatemala\n1F1EC 1F1FA - \; fully-qualified # 🇬🇺 E2.0 flag: Guam\n1F1EC - 1F1FC \; fully-qualified # 🇬🇼 E2. - 0 flag: Guinea-Bissau\n1F1EC 1F1FE \; fully - -qualified # 🇬🇾 E2.0 flag: Guyana\n1F1ED 1F1F0 - \; fully-qualified # 🇭🇰 E2.0 flag: Hong Kong SAR C - hina\n1F1ED 1F1F2 \; fully-qualified # - 🇭🇲 E2.0 flag: Heard & McDonald Islands\n1F1ED 1F1F3 - \; fully-qualified # 🇭🇳 E2.0 flag: Honduras\n1F1E - D 1F1F7 \; fully-qualified # 🇭🇷 E - 2.0 flag: Croatia\n1F1ED 1F1F9 \; fully-qua - lified # 🇭🇹 E2.0 flag: Haiti\n1F1ED 1F1FA - \; fully-qualified # 🇭🇺 E2.0 flag: Hungary\n1F1EE 1F1E8 - \; fully-qualified # 🇮🇨 E2.0 fla - g: Canary Islands\n1F1EE 1F1E9 \; fully-qua - lified # 🇮🇩 E2.0 flag: Indonesia\n1F1EE 1F1EA - \; fully-qualified # 🇮🇪 E2.0 flag: Ireland\n1F1EE 1 - F1F1 \; fully-qualified # 🇮🇱 E2.0 - flag: Israel\n1F1EE 1F1F2 \; fully-qualifi - ed # 🇮🇲 E2.0 flag: Isle of Man\n1F1EE 1F1F3 - \; fully-qualified # 🇮🇳 E2.0 flag: India\n1F1EE 1F1F4 - \; fully-qualified # 🇮🇴 E2.0 fla - g: British Indian Ocean Territory\n1F1EE 1F1F6 - \; fully-qualified # 🇮🇶 E2.0 flag: Iraq\n1F1EE 1F1F7 - \; fully-qualified # 🇮🇷 E2.0 flag: Iran\ - n1F1EE 1F1F8 \; fully-qualified # 🇮 - 🇸 E2.0 flag: Iceland\n1F1EE 1F1F9 \; ful - ly-qualified # 🇮🇹 E2.0 flag: Italy\n1F1EF 1F1EA - \; fully-qualified # 🇯🇪 E2.0 flag: Jersey\n1F1EF - 1F1F2 \; fully-qualified # 🇯🇲 E2. - 0 flag: Jamaica\n1F1EF 1F1F4 \; fully-quali - fied # 🇯🇴 E2.0 flag: Jordan\n1F1EF 1F1F5 - \; fully-qualified # 🇯🇵 E2.0 flag: Japan\n1F1F0 1F1EA - \; fully-qualified # 🇰🇪 E2.0 flag: - Kenya\n1F1F0 1F1EC \; fully-qualified # - 🇰🇬 E2.0 flag: Kyrgyzstan\n1F1F0 1F1ED - \; fully-qualified # 🇰🇭 E2.0 flag: Cambodia\n1F1F0 1F1EE - \; fully-qualified # 🇰🇮 E2.0 flag: Kir - ibati\n1F1F0 1F1F2 \; fully-qualified # - 🇰🇲 E2.0 flag: Comoros\n1F1F0 1F1F3 \ - ; fully-qualified # 🇰🇳 E2.0 flag: St. Kitts & Nevis\n1F1F0 1F1F5 - \; fully-qualified # 🇰🇵 E2.0 fla - g: North Korea\n1F1F0 1F1F7 \; fully-qualif - ied # 🇰🇷 E2.0 flag: South Korea\n1F1F0 1F1FC - \; fully-qualified # 🇰�� E2.0 flag: Kuwait\n1F1F0 1 - F1FE \; fully-qualified # 🇰🇾 E2.0 - flag: Cayman Islands\n1F1F0 1F1FF \; fully - -qualified # 🇰🇿 E2.0 flag: Kazakhstan\n1F1F1 1F1E6 - \; fully-qualified # 🇱🇦 E2.0 flag: Laos\n1F1F1 - 1F1E7 \; fully-qualified # 🇱🇧 E2 - .0 flag: Lebanon\n1F1F1 1F1E8 \; fully-qual - ified # 🇱🇨 E2.0 flag: St. Lucia\n1F1F1 1F1EE - \; fully-qualified # 🇱🇮 E2.0 flag: Liechtenstein\n1F - 1F1 1F1F0 \; fully-qualified # 🇱🇰 - E2.0 flag: Sri Lanka\n1F1F1 1F1F7 \; fully - -qualified # 🇱🇷 E2.0 flag: Liberia\n1F1F1 1F1F8 - \; fully-qualified # 🇱🇸 E2.0 flag: Lesotho\n1F1F1 - 1F1F9 \; fully-qualified # 🇱🇹 E2 - .0 flag: Lithuania\n1F1F1 1F1FA \; fully-qu - alified # 🇱🇺 E2.0 flag: Luxembourg\n1F1F1 1F1FB - \; fully-qualified # 🇱🇻 E2.0 flag: Latvia\n1F1F1 - 1F1FE \; fully-qualified # 🇱🇾 E2. - 0 flag: Libya\n1F1F2 1F1E6 \; fully-qualifi - ed # 🇲🇦 E2.0 flag: Morocco\n1F1F2 1F1E8 - \; fully-qualified # 🇲🇨 E2.0 flag: Monaco\n1F1F2 1F1E9 - \; fully-qualified # 🇲🇩 E2.0 flag: - Moldova\n1F1F2 1F1EA \; fully-qualified - # 🇲🇪 E2.0 flag: Montenegro\n1F1F2 1F1EB - \; fully-qualified # 🇲🇫 E2.0 flag: St. Martin\n1F1F2 1F1EC - \; fully-qualified # 🇲🇬 E2.0 flag: - Madagascar\n1F1F2 1F1ED \; fully-qualified - # 🇲🇭 E2.0 flag: Marshall Islands\n1F1F2 1F1F0 - \; fully-qualified # 🇲🇰 E2.0 flag: North Macedonia - \n1F1F2 1F1F1 \; fully-qualified # 🇲 - 🇱 E2.0 flag: Mali\n1F1F2 1F1F2 \; fully- - qualified # 🇲🇲 E2.0 flag: Myanmar (Burma)\n1F1F2 1F1F3 - \; fully-qualified # 🇲🇳 E2.0 flag: Mongoli - a\n1F1F2 1F1F4 \; fully-qualified # - 🇲🇴 E2.0 flag: Macao SAR China\n1F1F2 1F1F5 - \; fully-qualified # 🇲�� E2.0 flag: Northern Mariana Isla - nds\n1F1F2 1F1F6 \; fully-qualified # - 🇲🇶 E2.0 flag: Martinique\n1F1F2 1F1F7 - \; fully-qualified # 🇲🇷 E2.0 flag: Mauritania\n1F1F2 1F1F8 - \; fully-qualified # ��🇸 E2.0 flag: - Montserrat\n1F1F2 1F1F9 \; fully-qualified - # 🇲🇹 E2.0 flag: Malta\n1F1F2 1F1FA - \; fully-qualified # 🇲🇺 E2.0 flag: Mauritius\n1F1F2 1F1FB - \; fully-qualified # 🇲🇻 E2.0 flag: Ma - ldives\n1F1F2 1F1FC \; fully-qualified - # 🇲🇼 E2.0 flag: Malawi\n1F1F2 1F1FD \ - ; fully-qualified # 🇲🇽 E2.0 flag: Mexico\n1F1F2 1F1FE - \; fully-qualified # 🇲🇾 E2.0 flag: Malaysia - \n1F1F2 1F1FF \; fully-qualified # 🇲 - 🇿 E2.0 flag: Mozambique\n1F1F3 1F1E6 \; - fully-qualified # ��🇦 E2.0 flag: Namibia\n1F1F3 1F1E8 - \; fully-qualified # 🇳🇨 E2.0 flag: New Cal - edonia\n1F1F3 1F1EA \; fully-qualified - # 🇳🇪 E2.0 flag: Niger\n1F1F3 1F1EB \; - fully-qualified # 🇳🇫 E2.0 flag: Norfolk Island\n1F1F3 1F1EC - \; fully-qualified # 🇳🇬 E2.0 flag: N - igeria\n1F1F3 1F1EE \; fully-qualified - # 🇳🇮 E2.0 flag: Nicaragua\n1F1F3 1F1F1 - \; fully-qualified # 🇳🇱 E2.0 flag: Netherlands\n1F1F3 1F1F4 - \; fully-qualified # 🇳🇴 E2.0 flag: - Norway\n1F1F3 1F1F5 \; fully-qualified - # 🇳🇵 E2.0 flag: Nepal\n1F1F3 1F1F7 \; - fully-qualified # 🇳🇷 E2.0 flag: Nauru\n1F1F3 1F1FA - \; fully-qualified # 🇳🇺 E2.0 flag: Niue\n1F1F - 3 1F1FF \; fully-qualified # 🇳🇿 E - 2.0 flag: New Zealand\n1F1F4 1F1F2 \; fully - -qualified # 🇴🇲 E2.0 flag: Oman\n1F1F5 1F1E6 - \; fully-qualified # 🇵🇦 E2.0 flag: Panama\n1F1F5 1F1 - EA \; fully-qualified # 🇵🇪 E2.0 f - lag: Peru\n1F1F5 1F1EB \; fully-qualified - # 🇵🇫 E2.0 flag: French Polynesia\n1F1F5 1F1EC - \; fully-qualified # 🇵🇬 E2.0 flag: Papua New Guinea\ - n1F1F5 1F1ED \; fully-qualified # 🇵 - 🇭 E2.0 flag: Philippines\n1F1F5 1F1F0 \; - fully-qualified # 🇵🇰 E2.0 flag: Pakistan\n1F1F5 1F1F1 - \; fully-qualified # 🇵🇱 E2.0 flag: Poland\ - n1F1F5 1F1F2 \; fully-qualified # 🇵 - 🇲 E2.0 flag: St. Pierre & Miquelon\n1F1F5 1F1F3 - \; fully-qualified # 🇵🇳 E2.0 flag: Pitcairn Islands\n1F1 - F5 1F1F7 \; fully-qualified # 🇵🇷 - E2.0 flag: Puerto Rico\n1F1F5 1F1F8 \; full - y-qualified # 🇵🇸 E2.0 flag: Palestinian Territories\n1F1F5 1F1F9 - \; fully-qualified # 🇵🇹 E2.0 fla - g: Portugal\n1F1F5 1F1FC \; fully-qualified - # 🇵🇼 E2.0 flag: Palau\n1F1F5 1F1FE - \; fully-qualified # 🇵🇾 E2.0 flag: Paraguay\n1F1F6 1F1E6 - \; fully-qualified # 🇶🇦 E2.0 flag: Qa - tar\n1F1F7 1F1EA \; fully-qualified # - 🇷🇪 E2.0 flag: Réunion\n1F1F7 1F1F4 \ - ; fully-qualified # 🇷🇴 E2.0 flag: Romania\n1F1F7 1F1F8 - \; fully-qualified # 🇷🇸 E2.0 flag: Serbia\ - n1F1F7 1F1FA \; fully-qualified # 🇷 - 🇺 E2.0 flag: Russia\n1F1F7 1F1FC \; full - y-qualified # 🇷🇼 E2.0 flag: Rwanda\n1F1F8 1F1E6 - \; fully-qualified # 🇸🇦 E2.0 flag: Saudi Arabia\n - 1F1F8 1F1E7 \; fully-qualified # 🇸 - 🇧 E2.0 flag: Solomon Islands\n1F1F8 1F1E8 - \; fully-qualified # 🇸🇨 E2.0 flag: Seychelles\n1F1F8 1F1E9 - \; fully-qualified # 🇸🇩 E2.0 flag: S - udan\n1F1F8 1F1EA \; fully-qualified # - 🇸🇪 E2.0 flag: Sweden\n1F1F8 1F1EC \; - fully-qualified # 🇸🇬 E2.0 flag: Singapore\n1F1F8 1F1ED - \; fully-qualified # 🇸🇭 E2.0 flag: St. Hel - ena\n1F1F8 1F1EE \; fully-qualified # - 🇸🇮 E2.0 flag: Slovenia\n1F1F8 1F1EF \ - ; fully-qualified # 🇸�� E2.0 flag: Svalbard & Jan Mayen\n1F1F8 - 1F1F0 \; fully-qualified # 🇸🇰 E2. - 0 flag: Slovakia\n1F1F8 1F1F1 \; fully-qual - ified # 🇸🇱 E2.0 flag: Sierra Leone\n1F1F8 1F1F2 - \; fully-qualified # 🇸🇲 E2.0 flag: San Marino\n1F - 1F8 1F1F3 \; fully-qualified # 🇸🇳 - E2.0 flag: Senegal\n1F1F8 1F1F4 \; fully-q - ualified # 🇸🇴 E2.0 flag: Somalia\n1F1F8 1F1F7 - \; fully-qualified # 🇸🇷 E2.0 flag: Suriname\n1F1F8 - 1F1F8 \; fully-qualified # 🇸🇸 E2. - 0 flag: South Sudan\n1F1F8 1F1F9 \; fully-q - ualified # 🇸🇹 E2.0 flag: São Tomé & Príncipe\n1F1F8 1F1FB - \; fully-qualified # 🇸🇻 E2.0 flag: E - l Salvador\n1F1F8 1F1FD \; fully-qualified - # 🇸🇽 E2.0 flag: Sint Maarten\n1F1F8 1F1FE - \; fully-qualified # 🇸🇾 E2.0 flag: Syria\n1F1F8 1F1FF - \; fully-qualified # 🇸🇿 E2.0 flag: - Eswatini\n1F1F9 1F1E6 \; fully-qualified - # 🇹🇦 E2.0 flag: Tristan da Cunha\n1F1F9 1F1E8 - \; fully-qualified # 🇹🇨 E2.0 flag: Turks & Caicos Is - lands\n1F1F9 1F1E9 \; fully-qualified # - 🇹🇩 E2.0 flag: Chad\n1F1F9 1F1EB \; f - ully-qualified # 🇹🇫 E2.0 flag: French Southern Territories\n1F1F - 9 1F1EC \; fully-qualified # 🇹🇬 E - 2.0 flag: Togo\n1F1F9 1F1ED \; fully-qualif - ied # 🇹🇭 E2.0 flag: Thailand\n1F1F9 1F1EF - \; fully-qualified # 🇹🇯 E2.0 flag: Tajikistan\n1F1F9 1F - 1F0 \; fully-qualified # 🇹🇰 E2.0 - flag: Tokelau\n1F1F9 1F1F1 \; fully-qualifi - ed # 🇹🇱 E2.0 flag: Timor-Leste\n1F1F9 1F1F2 - \; fully-qualified # 🇹🇲 E2.0 flag: Turkmenistan\n1F1F - 9 1F1F3 \; fully-qualified # 🇹🇳 E - 2.0 flag: Tunisia\n1F1F9 1F1F4 \; fully-qua - lified # 🇹🇴 E2.0 flag: Tonga\n1F1F9 1F1F7 - \; fully-qualified # 🇹🇷 E2.0 flag: Turkey\n1F1F9 1F1F9 - \; fully-qualified # 🇹🇹 E2.0 flag - : Trinidad & Tobago\n1F1F9 1F1FB \; fully-q - ualified # 🇹🇻 E2.0 flag: Tuvalu\n1F1F9 1F1FC - \; fully-qualified # 🇹🇼 E2.0 flag: Taiwan\n1F1F9 1F1 - FF \; fully-qualified # 🇹🇿 E2.0 f - lag: Tanzania\n1F1FA 1F1E6 \; fully-qualifi - ed # 🇺🇦 E2.0 flag: Ukraine\n1F1FA 1F1EC - \; fully-qualified # 🇺🇬 E2.0 flag: Uganda\n1F1FA 1F1F2 - \; fully-qualified # 🇺🇲 E2.0 flag: - U.S. Outlying Islands\n1F1FA 1F1F3 \; fully - -qualified # 🇺🇳 E4.0 flag: United Nations\n1F1FA 1F1F8 - \; fully-qualified # 🇺🇸 E2.0 flag: United - States\n1F1FA 1F1FE \; fully-qualified - # 🇺🇾 E2.0 flag: Uruguay\n1F1FA 1F1FF - \; fully-qualified # 🇺🇿 E2.0 flag: Uzbekistan\n1F1FB 1F1E6 - \; fully-qualified # 🇻🇦 E2.0 flag: Vat - ican City\n1F1FB 1F1E8 \; fully-qualified - # 🇻🇨 E2.0 flag: St. Vincent & Grenadines\n1F1FB 1F1EA - \; fully-qualified # 🇻🇪 E2.0 flag: Venezuela - \n1F1FB 1F1EC \; fully-qualified # 🇻 - 🇬 E2.0 flag: British Virgin Islands\n1F1FB 1F1EE - \; fully-qualified # 🇻🇮 E2.0 flag: U.S. Virgin Islands\ - n1F1FB 1F1F3 \; fully-qualified # 🇻 - 🇳 E2.0 flag: Vietnam\n1F1FB 1F1FA \; ful - ly-qualified # 🇻🇺 E2.0 flag: Vanuatu\n1F1FC 1F1EB - \; fully-qualified # 🇼🇫 E2.0 flag: Wallis & Fut - una\n1F1FC 1F1F8 \; fully-qualified # - 🇼🇸 E2.0 flag: Samoa\n1F1FD 1F1F0 \; f - ully-qualified # 🇽🇰 E2.0 flag: Kosovo\n1F1FE 1F1EA - \; fully-qualified # 🇾🇪 E2.0 flag: Yemen\n1F1F - E 1F1F9 \; fully-qualified # 🇾🇹 E - 2.0 flag: Mayotte\n1F1FF 1F1E6 \; fully-qua - lified # 🇿🇦 E2.0 flag: South Africa\n1F1FF 1F1F2 - \; fully-qualified # 🇿🇲 E2.0 flag: Zambia\n1F1FF - 1F1FC \; fully-qualified # 🇿🇼 E2 - .0 flag: Zimbabwe\n\n# subgroup: subdivision-flag\n1F3F4 E0067 E0062 E0065 - E006E E0067 E007F \; fully-qualified # 🏴󠁧󠁢󠁥󠁮󠁧󠁿 - E5.0 flag: England\n1F3F4 E0067 E0062 E0073 E0063 E0074 E007F \; fully-qu - alified # 🏴󠁧󠁢󠁳󠁣󠁴󠁿 E5.0 flag: Scotland\n1F3F4 E006 - 7 E0062 E0077 E006C E0073 E007F \; fully-qualified # 🏴󠁧󠁢󠁷 - 󠁬󠁳󠁿 E5.0 flag: Wales\n\n# Flags subtotal: 271\n# Flags subtotal: - 271 w/o modifiers\n\n# Status Counts\n# fully-qualified : 3178\n# minima - lly-qualified : 589\n# unqualified : 246\n# component : 9\n\n#EOF -STATUS:CONFIRMED -DTSTART;TZID=Europe/Berlin:20200219T100000 -DTEND;TZID=Europe/Berlin:20200220T130000 -END:VEVENT -END:VCALENDAR diff --git a/tests/gehol/BA1.ics b/tests/gehol/BA1.ics deleted file mode 100644 index b866b50f..00000000 --- a/tests/gehol/BA1.ics +++ /dev/null @@ -1,1636 +0,0 @@ -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//hacksw/handcal//NONSGML v1.0//EN - -BEGIN:VTIMEZONE -TZID:Europe/Brussels -X-LIC-LOCATION:Europe/Brussels -BEGIN:STANDARD -DTSTART:20111030T020000 -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -TZNAME:CET -END:STANDARD -BEGIN:DAYLIGHT -DTSTART:20120325T030000 -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -TZNAME:CEST -END:DAYLIGHT -END:VTIMEZONE - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130930T120000 -DTEND;TZID=Europe/Brussels:20130930T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.2NO.506, P.2NO.607 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131007T120000 -DTEND;TZID=Europe/Brussels:20131007T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.2NO.506, P.2NO.607 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131014T120000 -DTEND;TZID=Europe/Brussels:20131014T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.2NO.506, P.2NO.607 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131021T120000 -DTEND;TZID=Europe/Brussels:20131021T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.2NO.506, P.2NO.607 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131028T120000 -DTEND;TZID=Europe/Brussels:20131028T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.2NO.506, P.2NO.607 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131104T120000 -DTEND;TZID=Europe/Brussels:20131104T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.2NO.506, P.2NO.607 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131118T120000 -DTEND;TZID=Europe/Brussels:20131118T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.2NO.506, P.2NO.607 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131125T120000 -DTEND;TZID=Europe/Brussels:20131125T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.2NO.506, P.2NO.607 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131202T120000 -DTEND;TZID=Europe/Brussels:20131202T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.2NO.506, P.2NO.607 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131209T120000 -DTEND;TZID=Europe/Brussels:20131209T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.2NO.506, P.2NO.607 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131216T120000 -DTEND;TZID=Europe/Brussels:20131216T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.2NO.506, P.2NO.607 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130923T100000 -DTEND;TZID=Europe/Brussels:20130923T120000 -SUMMARY: Programmation -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Massart, Thierry \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130930T100000 -DTEND;TZID=Europe/Brussels:20130930T120000 -SUMMARY: Programmation -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Massart, Thierry \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131014T100000 -DTEND;TZID=Europe/Brussels:20131014T120000 -SUMMARY: Programmation -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Massart, Thierry \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131021T100000 -DTEND;TZID=Europe/Brussels:20131021T120000 -SUMMARY: Programmation -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Massart, Thierry \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131118T100000 -DTEND;TZID=Europe/Brussels:20131118T120000 -SUMMARY: Programmation -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Massart, Thierry \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131125T100000 -DTEND;TZID=Europe/Brussels:20131125T120000 -SUMMARY: Programmation -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Massart, Thierry \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131202T100000 -DTEND;TZID=Europe/Brussels:20131202T120000 -SUMMARY: Programmation -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Massart, Thierry \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131209T100000 -DTEND;TZID=Europe/Brussels:20131209T120000 -SUMMARY: Programmation -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Massart, Thierry \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131118T120000 -DTEND;TZID=Europe/Brussels:20131118T140000 -SUMMARY: Introduction aux sciences de la terre -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: Regnier, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130923T140000 -DTEND;TZID=Europe/Brussels:20130923T160000 -SUMMARY: Fonctionnement des ordinateurs -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130930T140000 -DTEND;TZID=Europe/Brussels:20130930T160000 -SUMMARY: Fonctionnement des ordinateurs -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131007T140000 -DTEND;TZID=Europe/Brussels:20131007T160000 -SUMMARY: Fonctionnement des ordinateurs -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131014T140000 -DTEND;TZID=Europe/Brussels:20131014T160000 -SUMMARY: Fonctionnement des ordinateurs -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131021T140000 -DTEND;TZID=Europe/Brussels:20131021T160000 -SUMMARY: Fonctionnement des ordinateurs -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130916T090000 -DTEND;TZID=Europe/Brussels:20130916T120000 -SUMMARY: Accueil Facultaire Sciences -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131104T120000 -DTEND;TZID=Europe/Brussels:20131104T140000 -SUMMARY: Guidance en Physique -LOCATION: S.K.3.201 -DESCRIPTION: Professeur: Tlidi, Mustapha \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131118T120000 -DTEND;TZID=Europe/Brussels:20131118T140000 -SUMMARY: Guidance en Physique -LOCATION: S.K.3.201 -DESCRIPTION: Professeur: Tlidi, Mustapha \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131125T120000 -DTEND;TZID=Europe/Brussels:20131125T140000 -SUMMARY: Guidance en Physique -LOCATION: S.K.3.201 -DESCRIPTION: Professeur: Tlidi, Mustapha \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131202T120000 -DTEND;TZID=Europe/Brussels:20131202T140000 -SUMMARY: Guidance en Physique -LOCATION: S.K.3.201 -DESCRIPTION: Professeur: Tlidi, Mustapha \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131209T120000 -DTEND;TZID=Europe/Brussels:20131209T140000 -SUMMARY: Guidance en Physique -LOCATION: S.K.3.201 -DESCRIPTION: Professeur: Tlidi, Mustapha \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131216T120000 -DTEND;TZID=Europe/Brussels:20131216T140000 -SUMMARY: Guidance en Physique -LOCATION: S.K.3.201 -DESCRIPTION: Professeur: Tlidi, Mustapha \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20140203T120000 -DTEND;TZID=Europe/Brussels:20140203T140000 -SUMMARY: Guidance en Physique -LOCATION: S.K.3.201 -DESCRIPTION: Professeur: Tlidi, Mustapha \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20140210T120000 -DTEND;TZID=Europe/Brussels:20140210T140000 -SUMMARY: Guidance en Physique -LOCATION: S.K.3.201 -DESCRIPTION: Professeur: Tlidi, Mustapha \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20140217T120000 -DTEND;TZID=Europe/Brussels:20140217T140000 -SUMMARY: Guidance en Physique -LOCATION: S.K.3.201 -DESCRIPTION: Professeur: Tlidi, Mustapha \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20140224T120000 -DTEND;TZID=Europe/Brussels:20140224T140000 -SUMMARY: Guidance en Physique -LOCATION: S.K.3.201 -DESCRIPTION: Professeur: Tlidi, Mustapha \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20140303T120000 -DTEND;TZID=Europe/Brussels:20140303T140000 -SUMMARY: Guidance en Physique -LOCATION: S.K.3.201 -DESCRIPTION: Professeur: Tlidi, Mustapha \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20140310T120000 -DTEND;TZID=Europe/Brussels:20140310T140000 -SUMMARY: Guidance en Physique -LOCATION: S.K.3.201 -DESCRIPTION: Professeur: Tlidi, Mustapha \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20140317T120000 -DTEND;TZID=Europe/Brussels:20140317T140000 -SUMMARY: Guidance en Physique -LOCATION: S.K.3.201 -DESCRIPTION: Professeur: Tlidi, Mustapha \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20140324T120000 -DTEND;TZID=Europe/Brussels:20140324T140000 -SUMMARY: Guidance en Physique -LOCATION: S.K.3.201 -DESCRIPTION: Professeur: Tlidi, Mustapha \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20140331T120000 -DTEND;TZID=Europe/Brussels:20140331T140000 -SUMMARY: Guidance en Physique -LOCATION: S.K.3.201 -DESCRIPTION: Professeur: Tlidi, Mustapha \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20140428T120000 -DTEND;TZID=Europe/Brussels:20140428T140000 -SUMMARY: Guidance en Physique -LOCATION: S.K.3.201 -DESCRIPTION: Professeur: Tlidi, Mustapha \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20140505T120000 -DTEND;TZID=Europe/Brussels:20140505T140000 -SUMMARY: Guidance en Physique -LOCATION: S.K.3.201 -DESCRIPTION: Professeur: Tlidi, Mustapha \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130916T140000 -DTEND;TZID=Europe/Brussels:20130916T180000 -SUMMARY: Accueil Facultaire Sciences -LOCATION: P.2N3.208b -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130917T100000 -DTEND;TZID=Europe/Brussels:20130917T120000 -SUMMARY: Mathématiques 1 -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130924T100000 -DTEND;TZID=Europe/Brussels:20130924T120000 -SUMMARY: Mathématiques 1 -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131001T100000 -DTEND;TZID=Europe/Brussels:20131001T120000 -SUMMARY: Mathématiques 1 -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131008T100000 -DTEND;TZID=Europe/Brussels:20131008T120000 -SUMMARY: Mathématiques 1 -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131015T100000 -DTEND;TZID=Europe/Brussels:20131015T120000 -SUMMARY: Mathématiques 1 -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131022T100000 -DTEND;TZID=Europe/Brussels:20131022T120000 -SUMMARY: Mathématiques 1 -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131105T100000 -DTEND;TZID=Europe/Brussels:20131105T120000 -SUMMARY: Mathématiques 1 -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131112T100000 -DTEND;TZID=Europe/Brussels:20131112T120000 -SUMMARY: Mathématiques 1 -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131119T100000 -DTEND;TZID=Europe/Brussels:20131119T120000 -SUMMARY: Mathématiques 1 -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131126T100000 -DTEND;TZID=Europe/Brussels:20131126T120000 -SUMMARY: Mathématiques 1 -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131203T100000 -DTEND;TZID=Europe/Brussels:20131203T120000 -SUMMARY: Mathématiques 1 -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131210T100000 -DTEND;TZID=Europe/Brussels:20131210T120000 -SUMMARY: Mathématiques 1 -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130918T120000 -DTEND;TZID=Europe/Brussels:20130918T140000 -SUMMARY: Introduction aux sciences de la terre -LOCATION: S.UB2.252A -DESCRIPTION: Professeur: Regnier, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130925T120000 -DTEND;TZID=Europe/Brussels:20130925T140000 -SUMMARY: Introduction aux sciences de la terre -LOCATION: S.UB2.252A -DESCRIPTION: Professeur: Regnier, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131023T140000 -DTEND;TZID=Europe/Brussels:20131023T160000 -SUMMARY: Mathématiques 1 -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131002T120000 -DTEND;TZID=Europe/Brussels:20131002T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.2NO.607, P.2NO.707 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131009T120000 -DTEND;TZID=Europe/Brussels:20131009T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.2NO.607, P.2NO.707 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131016T120000 -DTEND;TZID=Europe/Brussels:20131016T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.2NO.607, P.2NO.707 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131023T120000 -DTEND;TZID=Europe/Brussels:20131023T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.2NO.607, P.2NO.707 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131030T120000 -DTEND;TZID=Europe/Brussels:20131030T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.2NO.607, P.2NO.707 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131106T120000 -DTEND;TZID=Europe/Brussels:20131106T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.2NO.607, P.2NO.707 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131113T120000 -DTEND;TZID=Europe/Brussels:20131113T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.2NO.607, P.2NO.707 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131127T120000 -DTEND;TZID=Europe/Brussels:20131127T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.2NO.607, P.2NO.707 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131204T120000 -DTEND;TZID=Europe/Brussels:20131204T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.2NO.607, P.2NO.707 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131211T120000 -DTEND;TZID=Europe/Brussels:20131211T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.2NO.607, P.2NO.707 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131218T120000 -DTEND;TZID=Europe/Brussels:20131218T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.2NO.607, P.2NO.707 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131009T140000 -DTEND;TZID=Europe/Brussels:20131009T160000 -SUMMARY: Mathématiques 1 -LOCATION: P.2NO906 -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131113T140000 -DTEND;TZID=Europe/Brussels:20131113T160000 -SUMMARY: Mathématiques 1 -LOCATION: P.2NO906 -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130918T080000 -DTEND;TZID=Europe/Brussels:20130918T100000 -SUMMARY: Histoire des sciences -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Marage, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130925T080000 -DTEND;TZID=Europe/Brussels:20130925T100000 -SUMMARY: Histoire des sciences -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Marage, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131002T080000 -DTEND;TZID=Europe/Brussels:20131002T100000 -SUMMARY: Histoire des sciences -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Marage, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131009T080000 -DTEND;TZID=Europe/Brussels:20131009T100000 -SUMMARY: Histoire des sciences -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Marage, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131016T080000 -DTEND;TZID=Europe/Brussels:20131016T100000 -SUMMARY: Histoire des sciences -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Marage, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131023T080000 -DTEND;TZID=Europe/Brussels:20131023T100000 -SUMMARY: Histoire des sciences -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Marage, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131106T080000 -DTEND;TZID=Europe/Brussels:20131106T100000 -SUMMARY: Histoire des sciences -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Marage, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131113T080000 -DTEND;TZID=Europe/Brussels:20131113T100000 -SUMMARY: Histoire des sciences -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Marage, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131127T080000 -DTEND;TZID=Europe/Brussels:20131127T100000 -SUMMARY: Histoire des sciences -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Marage, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131204T080000 -DTEND;TZID=Europe/Brussels:20131204T100000 -SUMMARY: Histoire des sciences -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Marage, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131211T080000 -DTEND;TZID=Europe/Brussels:20131211T100000 -SUMMARY: Histoire des sciences -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Marage, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131218T080000 -DTEND;TZID=Europe/Brussels:20131218T100000 -SUMMARY: Histoire des sciences -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Marage, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130918T100000 -DTEND;TZID=Europe/Brussels:20130918T120000 -SUMMARY: Société et environnement -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Decroly, Jean-Michel, Pattyn, Frank \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130925T100000 -DTEND;TZID=Europe/Brussels:20130925T120000 -SUMMARY: Société et environnement -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Decroly, Jean-Michel, Pattyn, Frank \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131002T100000 -DTEND;TZID=Europe/Brussels:20131002T120000 -SUMMARY: Société et environnement -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Decroly, Jean-Michel, Pattyn, Frank \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131009T100000 -DTEND;TZID=Europe/Brussels:20131009T120000 -SUMMARY: Société et environnement -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Decroly, Jean-Michel, Pattyn, Frank \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131016T100000 -DTEND;TZID=Europe/Brussels:20131016T120000 -SUMMARY: Société et environnement -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Decroly, Jean-Michel, Pattyn, Frank \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131023T100000 -DTEND;TZID=Europe/Brussels:20131023T120000 -SUMMARY: Société et environnement -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Decroly, Jean-Michel, Pattyn, Frank \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131106T100000 -DTEND;TZID=Europe/Brussels:20131106T120000 -SUMMARY: Société et environnement -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Decroly, Jean-Michel, Pattyn, Frank \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131113T100000 -DTEND;TZID=Europe/Brussels:20131113T120000 -SUMMARY: Société et environnement -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Decroly, Jean-Michel, Pattyn, Frank \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131127T100000 -DTEND;TZID=Europe/Brussels:20131127T120000 -SUMMARY: Société et environnement -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Decroly, Jean-Michel, Pattyn, Frank \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131204T100000 -DTEND;TZID=Europe/Brussels:20131204T120000 -SUMMARY: Société et environnement -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Decroly, Jean-Michel, Pattyn, Frank \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131211T100000 -DTEND;TZID=Europe/Brussels:20131211T120000 -SUMMARY: Société et environnement -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Decroly, Jean-Michel, Pattyn, Frank \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131009T120000 -DTEND;TZID=Europe/Brussels:20131009T140000 -SUMMARY: Introduction aux sciences de la terre -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Regnier, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131023T120000 -DTEND;TZID=Europe/Brussels:20131023T140000 -SUMMARY: Introduction aux sciences de la terre -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Regnier, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131106T120000 -DTEND;TZID=Europe/Brussels:20131106T140000 -SUMMARY: Introduction aux sciences de la terre -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Regnier, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131113T120000 -DTEND;TZID=Europe/Brussels:20131113T140000 -SUMMARY: Introduction aux sciences de la terre -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Regnier, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131127T120000 -DTEND;TZID=Europe/Brussels:20131127T140000 -SUMMARY: Introduction aux sciences de la terre -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Regnier, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131204T120000 -DTEND;TZID=Europe/Brussels:20131204T140000 -SUMMARY: Introduction aux sciences de la terre -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Regnier, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131211T120000 -DTEND;TZID=Europe/Brussels:20131211T140000 -SUMMARY: Introduction aux sciences de la terre -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Regnier, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131218T120000 -DTEND;TZID=Europe/Brussels:20131218T140000 -SUMMARY: Introduction aux sciences de la terre -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Regnier, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131211T140000 -DTEND;TZID=Europe/Brussels:20131211T160000 -SUMMARY: Mathématiques 1 -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131128T080000 -DTEND;TZID=Europe/Brussels:20131128T100000 -SUMMARY: Programmation -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Massart, Thierry \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131205T080000 -DTEND;TZID=Europe/Brussels:20131205T100000 -SUMMARY: Programmation -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Massart, Thierry \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131003T140000 -DTEND;TZID=Europe/Brussels:20131003T160000 -SUMMARY: Fonctionnement des ordinateurs -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131010T140000 -DTEND;TZID=Europe/Brussels:20131010T160000 -SUMMARY: Fonctionnement des ordinateurs -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131121T080000 -DTEND;TZID=Europe/Brussels:20131121T100000 -SUMMARY: Fonctionnement des ordinateurs -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131212T080000 -DTEND;TZID=Europe/Brussels:20131212T100000 -SUMMARY: Fonctionnement des ordinateurs -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131128T120000 -DTEND;TZID=Europe/Brussels:20131128T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.2NO.708 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131205T120000 -DTEND;TZID=Europe/Brussels:20131205T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.2NO.708 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131212T120000 -DTEND;TZID=Europe/Brussels:20131212T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.2NO.708 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130919T140000 -DTEND;TZID=Europe/Brussels:20130919T160000 -SUMMARY: Fonctionnement des ordinateurs -LOCATION: P.FORUM.E -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130919T080000 -DTEND;TZID=Europe/Brussels:20130919T100000 -SUMMARY: Programmation -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Massart, Thierry \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130926T080000 -DTEND;TZID=Europe/Brussels:20130926T100000 -SUMMARY: Programmation -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Massart, Thierry \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131003T080000 -DTEND;TZID=Europe/Brussels:20131003T100000 -SUMMARY: Programmation -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Massart, Thierry \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131010T080000 -DTEND;TZID=Europe/Brussels:20131010T100000 -SUMMARY: Programmation -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Massart, Thierry \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131017T080000 -DTEND;TZID=Europe/Brussels:20131017T100000 -SUMMARY: Programmation -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Massart, Thierry \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131024T080000 -DTEND;TZID=Europe/Brussels:20131024T100000 -SUMMARY: Programmation -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Massart, Thierry \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131107T080000 -DTEND;TZID=Europe/Brussels:20131107T100000 -SUMMARY: Programmation -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Massart, Thierry \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131114T080000 -DTEND;TZID=Europe/Brussels:20131114T100000 -SUMMARY: Programmation -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Massart, Thierry \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131031T113000 -DTEND;TZID=Europe/Brussels:20131031T133000 -SUMMARY: Programmation -LOCATION: P.FORUM.C, P.FORUM.A, P.FORUM.B -DESCRIPTION: Professeur: Massart, Thierry \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130926T140000 -DTEND;TZID=Europe/Brussels:20130926T160000 -SUMMARY: Fonctionnement des ordinateurs -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131017T140000 -DTEND;TZID=Europe/Brussels:20131017T160000 -SUMMARY: Fonctionnement des ordinateurs -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131024T140000 -DTEND;TZID=Europe/Brussels:20131024T160000 -SUMMARY: Fonctionnement des ordinateurs -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131107T140000 -DTEND;TZID=Europe/Brussels:20131107T160000 -SUMMARY: Fonctionnement des ordinateurs -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131114T140000 -DTEND;TZID=Europe/Brussels:20131114T160000 -SUMMARY: Fonctionnement des ordinateurs -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131121T140000 -DTEND;TZID=Europe/Brussels:20131121T160000 -SUMMARY: Fonctionnement des ordinateurs -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131128T140000 -DTEND;TZID=Europe/Brussels:20131128T160000 -SUMMARY: Fonctionnement des ordinateurs -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131205T140000 -DTEND;TZID=Europe/Brussels:20131205T160000 -SUMMARY: Fonctionnement des ordinateurs -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131212T140000 -DTEND;TZID=Europe/Brussels:20131212T160000 -SUMMARY: Fonctionnement des ordinateurs -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131219T140000 -DTEND;TZID=Europe/Brussels:20131219T160000 -SUMMARY: Fonctionnement des ordinateurs -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130919T160000 -DTEND;TZID=Europe/Brussels:20130919T180000 -SUMMARY: Mathématiques 1 -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130920T120000 -DTEND;TZID=Europe/Brussels:20130920T140000 -SUMMARY: Didactique de la réussite -LOCATION: S.UA2.220 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131004T120000 -DTEND;TZID=Europe/Brussels:20131004T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.OF.2070, P.OF.2072 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131011T120000 -DTEND;TZID=Europe/Brussels:20131011T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.OF.2070, P.OF.2072 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131018T120000 -DTEND;TZID=Europe/Brussels:20131018T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.OF.2070, P.OF.2072 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131025T120000 -DTEND;TZID=Europe/Brussels:20131025T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.OF.2070, P.OF.2072 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131108T120000 -DTEND;TZID=Europe/Brussels:20131108T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.OF.2070, P.OF.2072 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131115T120000 -DTEND;TZID=Europe/Brussels:20131115T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.OF.2070, P.OF.2072 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131122T120000 -DTEND;TZID=Europe/Brussels:20131122T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.OF.2070, P.OF.2072 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131129T120000 -DTEND;TZID=Europe/Brussels:20131129T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.OF.2070, P.OF.2072 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131206T120000 -DTEND;TZID=Europe/Brussels:20131206T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.OF.2070, P.OF.2072 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131213T120000 -DTEND;TZID=Europe/Brussels:20131213T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.OF.2070, P.OF.2072 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131220T120000 -DTEND;TZID=Europe/Brussels:20131220T140000 -SUMMARY: Guidance en Mathématique -LOCATION: P.OF.2070, P.OF.2072 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131011T120000 -DTEND;TZID=Europe/Brussels:20131011T140000 -SUMMARY: Guidance en Physique -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131018T120000 -DTEND;TZID=Europe/Brussels:20131018T140000 -SUMMARY: Guidance en Physique -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131025T120000 -DTEND;TZID=Europe/Brussels:20131025T140000 -SUMMARY: Guidance en Physique -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131108T120000 -DTEND;TZID=Europe/Brussels:20131108T140000 -SUMMARY: Guidance en Physique -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131115T120000 -DTEND;TZID=Europe/Brussels:20131115T140000 -SUMMARY: Guidance en Physique -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131122T120000 -DTEND;TZID=Europe/Brussels:20131122T140000 -SUMMARY: Guidance en Physique -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131129T120000 -DTEND;TZID=Europe/Brussels:20131129T140000 -SUMMARY: Guidance en Physique -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131206T120000 -DTEND;TZID=Europe/Brussels:20131206T140000 -SUMMARY: Guidance en Physique -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131213T120000 -DTEND;TZID=Europe/Brussels:20131213T140000 -SUMMARY: Guidance en Physique -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131220T120000 -DTEND;TZID=Europe/Brussels:20131220T140000 -SUMMARY: Guidance en Physique -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20140214T120000 -DTEND;TZID=Europe/Brussels:20140214T140000 -SUMMARY: Guidance en Physique -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20140221T120000 -DTEND;TZID=Europe/Brussels:20140221T140000 -SUMMARY: Guidance en Physique -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20140228T120000 -DTEND;TZID=Europe/Brussels:20140228T140000 -SUMMARY: Guidance en Physique -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20140307T120000 -DTEND;TZID=Europe/Brussels:20140307T140000 -SUMMARY: Guidance en Physique -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20140314T120000 -DTEND;TZID=Europe/Brussels:20140314T140000 -SUMMARY: Guidance en Physique -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20140321T120000 -DTEND;TZID=Europe/Brussels:20140321T140000 -SUMMARY: Guidance en Physique -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20140328T120000 -DTEND;TZID=Europe/Brussels:20140328T140000 -SUMMARY: Guidance en Physique -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20140404T120000 -DTEND;TZID=Europe/Brussels:20140404T140000 -SUMMARY: Guidance en Physique -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20140425T120000 -DTEND;TZID=Europe/Brussels:20140425T140000 -SUMMARY: Guidance en Physique -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20140502T120000 -DTEND;TZID=Europe/Brussels:20140502T140000 -SUMMARY: Guidance en Physique -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20140509T120000 -DTEND;TZID=Europe/Brussels:20140509T140000 -SUMMARY: Guidance en Physique -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130920T140000 -DTEND;TZID=Europe/Brussels:20130920T160000 -SUMMARY: Mathématiques 1 -LOCATION: P.FORUM.E -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131004T140000 -DTEND;TZID=Europe/Brussels:20131004T160000 -SUMMARY: Mathématiques 1 -LOCATION: P.FORUM.E -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131011T140000 -DTEND;TZID=Europe/Brussels:20131011T160000 -SUMMARY: Mathématiques 1 -LOCATION: P.FORUM.E -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131018T140000 -DTEND;TZID=Europe/Brussels:20131018T160000 -SUMMARY: Mathématiques 1 -LOCATION: P.FORUM.E -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131025T140000 -DTEND;TZID=Europe/Brussels:20131025T160000 -SUMMARY: Mathématiques 1 -LOCATION: P.FORUM.E -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131108T130000 -DTEND;TZID=Europe/Brussels:20131108T150000 -SUMMARY: Mathématiques 1 -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131115T130000 -DTEND;TZID=Europe/Brussels:20131115T150000 -SUMMARY: Mathématiques 1 -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131122T130000 -DTEND;TZID=Europe/Brussels:20131122T150000 -SUMMARY: Mathématiques 1 -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131129T130000 -DTEND;TZID=Europe/Brussels:20131129T150000 -SUMMARY: Mathématiques 1 -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131206T130000 -DTEND;TZID=Europe/Brussels:20131206T150000 -SUMMARY: Mathématiques 1 -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131213T130000 -DTEND;TZID=Europe/Brussels:20131213T150000 -SUMMARY: Mathématiques 1 -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131220T130000 -DTEND;TZID=Europe/Brussels:20131220T150000 -SUMMARY: Mathématiques 1 -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Federinov, Julien \n Assistant -END:VEVENT - - -END:VCALENDAR diff --git a/tests/gehol/BA2.ics b/tests/gehol/BA2.ics deleted file mode 100644 index 840ccaa3..00000000 --- a/tests/gehol/BA2.ics +++ /dev/null @@ -1,2239 +0,0 @@ -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//hacksw/handcal//NONSGML v1.0//EN - -BEGIN:VTIMEZONE -TZID:Europe/Brussels -X-LIC-LOCATION:Europe/Brussels -BEGIN:STANDARD -DTSTART:20111030T020000 -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -TZNAME:CET -END:STANDARD -BEGIN:DAYLIGHT -DTSTART:20120325T030000 -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -TZNAME:CEST -END:DAYLIGHT -END:VTIMEZONE - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130930T120000 -DTEND;TZID=Europe/Brussels:20130930T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.H2213 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131007T120000 -DTEND;TZID=Europe/Brussels:20131007T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.H2213 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131014T120000 -DTEND;TZID=Europe/Brussels:20131014T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.H2213 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131021T120000 -DTEND;TZID=Europe/Brussels:20131021T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.H2213 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131216T180000 -DTEND;TZID=Europe/Brussels:20131216T200000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.K.1.105, S.JANSON -DESCRIPTION: Professeur: Castanheira De Moura, Micael \n Assistant interro récap -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131104T120000 -DTEND;TZID=Europe/Brussels:20131104T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.H1309 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131118T120000 -DTEND;TZID=Europe/Brussels:20131118T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.H1309 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131125T120000 -DTEND;TZID=Europe/Brussels:20131125T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.H1309 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131202T120000 -DTEND;TZID=Europe/Brussels:20131202T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.H1309 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131007T140000 -DTEND;TZID=Europe/Brussels:20131007T160000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.R42.5.103 -DESCRIPTION: Professeur: \n Assistant Gr Sciences -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131209T120000 -DTEND;TZID=Europe/Brussels:20131209T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.R42.4.502 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130930T140000 -DTEND;TZID=Europe/Brussels:20130930T160000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.R42.5.107 -DESCRIPTION: Professeur: \n Assistant Gr Sciences -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130923T080000 -DTEND;TZID=Europe/Brussels:20130923T100000 -SUMMARY: Algorithmique 2 -LOCATION: P.2NO.607 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130930T080000 -DTEND;TZID=Europe/Brussels:20130930T100000 -SUMMARY: Algorithmique 2 -LOCATION: P.2NO.607 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131007T080000 -DTEND;TZID=Europe/Brussels:20131007T100000 -SUMMARY: Algorithmique 2 -LOCATION: P.2NO.607 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131014T080000 -DTEND;TZID=Europe/Brussels:20131014T100000 -SUMMARY: Algorithmique 2 -LOCATION: P.2NO.607 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131021T080000 -DTEND;TZID=Europe/Brussels:20131021T100000 -SUMMARY: Algorithmique 2 -LOCATION: P.2NO.607 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131028T080000 -DTEND;TZID=Europe/Brussels:20131028T100000 -SUMMARY: Algorithmique 2 -LOCATION: P.2NO.607 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130923T100000 -DTEND;TZID=Europe/Brussels:20130923T120000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: S.UD2.120 -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130930T100000 -DTEND;TZID=Europe/Brussels:20130930T120000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: S.UD2.120 -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131007T100000 -DTEND;TZID=Europe/Brussels:20131007T120000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: S.UD2.120 -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131014T100000 -DTEND;TZID=Europe/Brussels:20131014T120000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: S.UD2.120 -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131021T100000 -DTEND;TZID=Europe/Brussels:20131021T120000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: S.UD2.120 -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131028T100000 -DTEND;TZID=Europe/Brussels:20131028T120000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: S.UD2.120 -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131104T100000 -DTEND;TZID=Europe/Brussels:20131104T120000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: S.UD2.120 -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131118T100000 -DTEND;TZID=Europe/Brussels:20131118T120000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: S.UD2.120 -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131125T100000 -DTEND;TZID=Europe/Brussels:20131125T120000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: S.UD2.120 -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131202T100000 -DTEND;TZID=Europe/Brussels:20131202T120000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: S.UD2.120 -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131209T100000 -DTEND;TZID=Europe/Brussels:20131209T120000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: S.UD2.120 -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131216T100000 -DTEND;TZID=Europe/Brussels:20131216T120000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: S.UD2.120 -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131118T120000 -DTEND;TZID=Europe/Brussels:20131118T140000 -SUMMARY: Introduction aux sciences de la terre -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: Regnier, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130923T160000 -DTEND;TZID=Europe/Brussels:20130923T180000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.JANSON -DESCRIPTION: Professeur: Castanheira De Moura, Micael \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130930T160000 -DTEND;TZID=Europe/Brussels:20130930T180000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.JANSON -DESCRIPTION: Professeur: Castanheira De Moura, Micael \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131007T160000 -DTEND;TZID=Europe/Brussels:20131007T180000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.JANSON -DESCRIPTION: Professeur: Castanheira De Moura, Micael \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131014T160000 -DTEND;TZID=Europe/Brussels:20131014T180000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.JANSON -DESCRIPTION: Professeur: Castanheira De Moura, Micael \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131021T160000 -DTEND;TZID=Europe/Brussels:20131021T180000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.JANSON -DESCRIPTION: Professeur: Castanheira De Moura, Micael \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131028T160000 -DTEND;TZID=Europe/Brussels:20131028T180000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.JANSON -DESCRIPTION: Professeur: Castanheira De Moura, Micael \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131104T160000 -DTEND;TZID=Europe/Brussels:20131104T180000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.JANSON -DESCRIPTION: Professeur: Castanheira De Moura, Micael \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131118T160000 -DTEND;TZID=Europe/Brussels:20131118T180000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.JANSON -DESCRIPTION: Professeur: Castanheira De Moura, Micael \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131125T160000 -DTEND;TZID=Europe/Brussels:20131125T180000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.JANSON -DESCRIPTION: Professeur: Castanheira De Moura, Micael \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131202T160000 -DTEND;TZID=Europe/Brussels:20131202T180000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.JANSON -DESCRIPTION: Professeur: Castanheira De Moura, Micael \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131104T080000 -DTEND;TZID=Europe/Brussels:20131104T100000 -SUMMARY: Systèmes d'exploitation -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131118T080000 -DTEND;TZID=Europe/Brussels:20131118T100000 -SUMMARY: Systèmes d'exploitation -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131125T080000 -DTEND;TZID=Europe/Brussels:20131125T100000 -SUMMARY: Systèmes d'exploitation -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131202T080000 -DTEND;TZID=Europe/Brussels:20131202T100000 -SUMMARY: Systèmes d'exploitation -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131209T080000 -DTEND;TZID=Europe/Brussels:20131209T100000 -SUMMARY: Systèmes d'exploitation -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131216T080000 -DTEND;TZID=Europe/Brussels:20131216T100000 -SUMMARY: Systèmes d'exploitation -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131104T133000 -DTEND;TZID=Europe/Brussels:20131104T153000 -SUMMARY: Algorithmique 2 -LOCATION: P.FORUM.H -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131118T133000 -DTEND;TZID=Europe/Brussels:20131118T153000 -SUMMARY: Algorithmique 2 -LOCATION: P.FORUM.H -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131125T133000 -DTEND;TZID=Europe/Brussels:20131125T153000 -SUMMARY: Algorithmique 2 -LOCATION: P.FORUM.H -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131202T133000 -DTEND;TZID=Europe/Brussels:20131202T153000 -SUMMARY: Algorithmique 2 -LOCATION: P.FORUM.H -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131209T133000 -DTEND;TZID=Europe/Brussels:20131209T153000 -SUMMARY: Algorithmique 2 -LOCATION: P.FORUM.H -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131216T133000 -DTEND;TZID=Europe/Brussels:20131216T153000 -SUMMARY: Algorithmique 2 -LOCATION: P.FORUM.H -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131022T093000 -DTEND;TZID=Europe/Brussels:20131022T103000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: P.A2.122 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131008T093000 -DTEND;TZID=Europe/Brussels:20131008T103000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: P.A2.120 -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131105T080000 -DTEND;TZID=Europe/Brussels:20131105T100000 -SUMMARY: Systèmes d'exploitation -LOCATION: P.2NO4.008.PC -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131029T080000 -DTEND;TZID=Europe/Brussels:20131029T100000 -SUMMARY: Analyse et méthodes -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131112T080000 -DTEND;TZID=Europe/Brussels:20131112T100000 -SUMMARY: Analyse et méthodes -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131119T080000 -DTEND;TZID=Europe/Brussels:20131119T100000 -SUMMARY: Analyse et méthodes -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131126T080000 -DTEND;TZID=Europe/Brussels:20131126T100000 -SUMMARY: Analyse et méthodes -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131203T080000 -DTEND;TZID=Europe/Brussels:20131203T100000 -SUMMARY: Analyse et méthodes -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131210T080000 -DTEND;TZID=Europe/Brussels:20131210T100000 -SUMMARY: Analyse et méthodes -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131217T080000 -DTEND;TZID=Europe/Brussels:20131217T100000 -SUMMARY: Analyse et méthodes -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130917T170000 -DTEND;TZID=Europe/Brussels:20130917T190000 -SUMMARY: Analyse et méthodes -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Hernalsteen, Christian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130924T170000 -DTEND;TZID=Europe/Brussels:20130924T190000 -SUMMARY: Analyse et méthodes -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Hernalsteen, Christian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131001T170000 -DTEND;TZID=Europe/Brussels:20131001T190000 -SUMMARY: Analyse et méthodes -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Hernalsteen, Christian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131008T170000 -DTEND;TZID=Europe/Brussels:20131008T190000 -SUMMARY: Analyse et méthodes -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Hernalsteen, Christian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131015T170000 -DTEND;TZID=Europe/Brussels:20131015T190000 -SUMMARY: Analyse et méthodes -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Hernalsteen, Christian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131022T170000 -DTEND;TZID=Europe/Brussels:20131022T190000 -SUMMARY: Analyse et méthodes -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Hernalsteen, Christian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131105T170000 -DTEND;TZID=Europe/Brussels:20131105T190000 -SUMMARY: Analyse et méthodes -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Hernalsteen, Christian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131112T170000 -DTEND;TZID=Europe/Brussels:20131112T190000 -SUMMARY: Analyse et méthodes -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Hernalsteen, Christian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131119T170000 -DTEND;TZID=Europe/Brussels:20131119T190000 -SUMMARY: Analyse et méthodes -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Hernalsteen, Christian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131126T170000 -DTEND;TZID=Europe/Brussels:20131126T190000 -SUMMARY: Analyse et méthodes -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Hernalsteen, Christian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131203T170000 -DTEND;TZID=Europe/Brussels:20131203T190000 -SUMMARY: Analyse et méthodes -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Hernalsteen, Christian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131210T170000 -DTEND;TZID=Europe/Brussels:20131210T190000 -SUMMARY: Analyse et méthodes -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Hernalsteen, Christian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131015T090000 -DTEND;TZID=Europe/Brussels:20131015T100000 -SUMMARY: Projets d'informatique 2 -LOCATION: P.2NO.708 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130924T103000 -DTEND;TZID=Europe/Brussels:20130924T123000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: P.2NO.708 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131001T103000 -DTEND;TZID=Europe/Brussels:20131001T123000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: P.2NO.708 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131008T103000 -DTEND;TZID=Europe/Brussels:20131008T123000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: P.2NO.708 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131015T103000 -DTEND;TZID=Europe/Brussels:20131015T123000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: P.2NO.708 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131022T103000 -DTEND;TZID=Europe/Brussels:20131022T123000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: P.2NO.708 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131105T103000 -DTEND;TZID=Europe/Brussels:20131105T123000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: P.2NO.708 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131112T103000 -DTEND;TZID=Europe/Brussels:20131112T123000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: P.2NO.708 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131119T103000 -DTEND;TZID=Europe/Brussels:20131119T123000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: P.2NO.708 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131126T103000 -DTEND;TZID=Europe/Brussels:20131126T123000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: P.2NO.708 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131203T103000 -DTEND;TZID=Europe/Brussels:20131203T123000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: P.2NO.708 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131210T103000 -DTEND;TZID=Europe/Brussels:20131210T123000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: P.2NO.708 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131217T103000 -DTEND;TZID=Europe/Brussels:20131217T123000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: P.2NO.708 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130917T133000 -DTEND;TZID=Europe/Brussels:20130917T160000 -SUMMARY: Systèmes d'exploitation -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: Goossens, Joël \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130924T133000 -DTEND;TZID=Europe/Brussels:20130924T160000 -SUMMARY: Systèmes d'exploitation -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: Goossens, Joël \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131001T133000 -DTEND;TZID=Europe/Brussels:20131001T160000 -SUMMARY: Systèmes d'exploitation -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: Goossens, Joël \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131008T133000 -DTEND;TZID=Europe/Brussels:20131008T160000 -SUMMARY: Systèmes d'exploitation -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: Goossens, Joël \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131022T133000 -DTEND;TZID=Europe/Brussels:20131022T160000 -SUMMARY: Systèmes d'exploitation -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: Goossens, Joël \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131029T133000 -DTEND;TZID=Europe/Brussels:20131029T160000 -SUMMARY: Systèmes d'exploitation -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: Goossens, Joël \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131105T133000 -DTEND;TZID=Europe/Brussels:20131105T160000 -SUMMARY: Systèmes d'exploitation -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: Goossens, Joël \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131112T133000 -DTEND;TZID=Europe/Brussels:20131112T160000 -SUMMARY: Systèmes d'exploitation -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: Goossens, Joël \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131119T133000 -DTEND;TZID=Europe/Brussels:20131119T160000 -SUMMARY: Systèmes d'exploitation -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: Goossens, Joël \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131126T133000 -DTEND;TZID=Europe/Brussels:20131126T160000 -SUMMARY: Systèmes d'exploitation -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: Goossens, Joël \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131203T133000 -DTEND;TZID=Europe/Brussels:20131203T160000 -SUMMARY: Systèmes d'exploitation -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: Goossens, Joël \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131218T080000 -DTEND;TZID=Europe/Brussels:20131218T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109 -DESCRIPTION: Professeur: Lucy, Gillian \n Assistant Rattrapage Mme Lucy -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130918T120000 -DTEND;TZID=Europe/Brussels:20130918T140000 -SUMMARY: Introduction aux sciences de la terre -LOCATION: S.UB2.252A -DESCRIPTION: Professeur: Regnier, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130925T120000 -DTEND;TZID=Europe/Brussels:20130925T140000 -SUMMARY: Introduction aux sciences de la terre -LOCATION: S.UB2.252A -DESCRIPTION: Professeur: Regnier, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131106T160000 -DTEND;TZID=Europe/Brussels:20131106T180000 -SUMMARY: Algorithmique 2 -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130925T080000 -DTEND;TZID=Europe/Brussels:20130925T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.1.111 -DESCRIPTION: Professeur: Lucy, Gillian, Essex, Richard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131002T080000 -DTEND;TZID=Europe/Brussels:20131002T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.1.111 -DESCRIPTION: Professeur: Lucy, Gillian, Essex, Richard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131009T080000 -DTEND;TZID=Europe/Brussels:20131009T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.1.111 -DESCRIPTION: Professeur: Lucy, Gillian, Essex, Richard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131016T080000 -DTEND;TZID=Europe/Brussels:20131016T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.1.111 -DESCRIPTION: Professeur: Lucy, Gillian, Essex, Richard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131023T080000 -DTEND;TZID=Europe/Brussels:20131023T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.1.111 -DESCRIPTION: Professeur: Lucy, Gillian, Essex, Richard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131106T080000 -DTEND;TZID=Europe/Brussels:20131106T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.1.111 -DESCRIPTION: Professeur: Lucy, Gillian, Essex, Richard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131113T080000 -DTEND;TZID=Europe/Brussels:20131113T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.1.111 -DESCRIPTION: Professeur: Lucy, Gillian, Essex, Richard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131127T080000 -DTEND;TZID=Europe/Brussels:20131127T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.1.111 -DESCRIPTION: Professeur: Lucy, Gillian, Essex, Richard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131204T080000 -DTEND;TZID=Europe/Brussels:20131204T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.1.111 -DESCRIPTION: Professeur: Lucy, Gillian, Essex, Richard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131211T080000 -DTEND;TZID=Europe/Brussels:20131211T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.1.111 -DESCRIPTION: Professeur: Lucy, Gillian, Essex, Richard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131009T120000 -DTEND;TZID=Europe/Brussels:20131009T140000 -SUMMARY: Introduction aux sciences de la terre -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Regnier, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131023T120000 -DTEND;TZID=Europe/Brussels:20131023T140000 -SUMMARY: Introduction aux sciences de la terre -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Regnier, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131106T120000 -DTEND;TZID=Europe/Brussels:20131106T140000 -SUMMARY: Introduction aux sciences de la terre -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Regnier, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131113T120000 -DTEND;TZID=Europe/Brussels:20131113T140000 -SUMMARY: Introduction aux sciences de la terre -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Regnier, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131127T120000 -DTEND;TZID=Europe/Brussels:20131127T140000 -SUMMARY: Introduction aux sciences de la terre -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Regnier, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131204T120000 -DTEND;TZID=Europe/Brussels:20131204T140000 -SUMMARY: Introduction aux sciences de la terre -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Regnier, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131211T120000 -DTEND;TZID=Europe/Brussels:20131211T140000 -SUMMARY: Introduction aux sciences de la terre -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Regnier, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131218T120000 -DTEND;TZID=Europe/Brussels:20131218T140000 -SUMMARY: Introduction aux sciences de la terre -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Regnier, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131211T160000 -DTEND;TZID=Europe/Brussels:20131211T170000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.UB2.147 -DESCRIPTION: Professeur: \n Assistant Gr Sciences -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130918T080000 -DTEND;TZID=Europe/Brussels:20130918T100000 -SUMMARY: Histoire des sciences -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Marage, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130925T080000 -DTEND;TZID=Europe/Brussels:20130925T100000 -SUMMARY: Histoire des sciences -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Marage, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131002T080000 -DTEND;TZID=Europe/Brussels:20131002T100000 -SUMMARY: Histoire des sciences -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Marage, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131009T080000 -DTEND;TZID=Europe/Brussels:20131009T100000 -SUMMARY: Histoire des sciences -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Marage, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131016T080000 -DTEND;TZID=Europe/Brussels:20131016T100000 -SUMMARY: Histoire des sciences -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Marage, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131023T080000 -DTEND;TZID=Europe/Brussels:20131023T100000 -SUMMARY: Histoire des sciences -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Marage, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131106T080000 -DTEND;TZID=Europe/Brussels:20131106T100000 -SUMMARY: Histoire des sciences -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Marage, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131113T080000 -DTEND;TZID=Europe/Brussels:20131113T100000 -SUMMARY: Histoire des sciences -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Marage, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131127T080000 -DTEND;TZID=Europe/Brussels:20131127T100000 -SUMMARY: Histoire des sciences -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Marage, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131204T080000 -DTEND;TZID=Europe/Brussels:20131204T100000 -SUMMARY: Histoire des sciences -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Marage, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131211T080000 -DTEND;TZID=Europe/Brussels:20131211T100000 -SUMMARY: Histoire des sciences -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Marage, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131218T080000 -DTEND;TZID=Europe/Brussels:20131218T100000 -SUMMARY: Histoire des sciences -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Marage, Pierre \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130918T100000 -DTEND;TZID=Europe/Brussels:20130918T120000 -SUMMARY: Société et environnement -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Decroly, Jean-Michel, Pattyn, Frank \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130925T100000 -DTEND;TZID=Europe/Brussels:20130925T120000 -SUMMARY: Société et environnement -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Decroly, Jean-Michel, Pattyn, Frank \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131002T100000 -DTEND;TZID=Europe/Brussels:20131002T120000 -SUMMARY: Société et environnement -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Decroly, Jean-Michel, Pattyn, Frank \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131009T100000 -DTEND;TZID=Europe/Brussels:20131009T120000 -SUMMARY: Société et environnement -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Decroly, Jean-Michel, Pattyn, Frank \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131016T100000 -DTEND;TZID=Europe/Brussels:20131016T120000 -SUMMARY: Société et environnement -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Decroly, Jean-Michel, Pattyn, Frank \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131023T100000 -DTEND;TZID=Europe/Brussels:20131023T120000 -SUMMARY: Société et environnement -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Decroly, Jean-Michel, Pattyn, Frank \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131106T100000 -DTEND;TZID=Europe/Brussels:20131106T120000 -SUMMARY: Société et environnement -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Decroly, Jean-Michel, Pattyn, Frank \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131113T100000 -DTEND;TZID=Europe/Brussels:20131113T120000 -SUMMARY: Société et environnement -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Decroly, Jean-Michel, Pattyn, Frank \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131127T100000 -DTEND;TZID=Europe/Brussels:20131127T120000 -SUMMARY: Société et environnement -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Decroly, Jean-Michel, Pattyn, Frank \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131204T100000 -DTEND;TZID=Europe/Brussels:20131204T120000 -SUMMARY: Société et environnement -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Decroly, Jean-Michel, Pattyn, Frank \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131211T100000 -DTEND;TZID=Europe/Brussels:20131211T120000 -SUMMARY: Société et environnement -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Decroly, Jean-Michel, Pattyn, Frank \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130918T120000 -DTEND;TZID=Europe/Brussels:20130918T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.JANSON -DESCRIPTION: Professeur: Castanheira De Moura, Micael \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130925T120000 -DTEND;TZID=Europe/Brussels:20130925T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.JANSON -DESCRIPTION: Professeur: Castanheira De Moura, Micael \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131002T120000 -DTEND;TZID=Europe/Brussels:20131002T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.JANSON -DESCRIPTION: Professeur: Castanheira De Moura, Micael \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131009T120000 -DTEND;TZID=Europe/Brussels:20131009T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.JANSON -DESCRIPTION: Professeur: Castanheira De Moura, Micael \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131016T120000 -DTEND;TZID=Europe/Brussels:20131016T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.JANSON -DESCRIPTION: Professeur: Castanheira De Moura, Micael \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131023T120000 -DTEND;TZID=Europe/Brussels:20131023T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.JANSON -DESCRIPTION: Professeur: Castanheira De Moura, Micael \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131106T120000 -DTEND;TZID=Europe/Brussels:20131106T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.JANSON -DESCRIPTION: Professeur: Castanheira De Moura, Micael \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131113T120000 -DTEND;TZID=Europe/Brussels:20131113T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.JANSON -DESCRIPTION: Professeur: Castanheira De Moura, Micael \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131127T120000 -DTEND;TZID=Europe/Brussels:20131127T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.JANSON -DESCRIPTION: Professeur: Castanheira De Moura, Micael \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131204T120000 -DTEND;TZID=Europe/Brussels:20131204T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.JANSON -DESCRIPTION: Professeur: Castanheira De Moura, Micael \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131002T140000 -DTEND;TZID=Europe/Brussels:20131002T160000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.UD2.208 -DESCRIPTION: Professeur: \n Assistant Gr Sciences -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131009T140000 -DTEND;TZID=Europe/Brussels:20131009T160000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.UD2.208 -DESCRIPTION: Professeur: \n Assistant Gr Sciences -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131016T140000 -DTEND;TZID=Europe/Brussels:20131016T160000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.UD2.208 -DESCRIPTION: Professeur: \n Assistant Gr Sciences -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131023T140000 -DTEND;TZID=Europe/Brussels:20131023T160000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.UD2.208 -DESCRIPTION: Professeur: \n Assistant Gr Sciences -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131106T140000 -DTEND;TZID=Europe/Brussels:20131106T160000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.UD2.208 -DESCRIPTION: Professeur: \n Assistant Gr Sciences -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131113T140000 -DTEND;TZID=Europe/Brussels:20131113T160000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.UD2.208 -DESCRIPTION: Professeur: \n Assistant Gr Sciences -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131127T140000 -DTEND;TZID=Europe/Brussels:20131127T160000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.UD2.208 -DESCRIPTION: Professeur: \n Assistant Gr Sciences -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131204T140000 -DTEND;TZID=Europe/Brussels:20131204T160000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.UD2.208 -DESCRIPTION: Professeur: \n Assistant Gr Sciences -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131211T140000 -DTEND;TZID=Europe/Brussels:20131211T160000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.UD2.208 -DESCRIPTION: Professeur: \n Assistant Gr Sciences -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131218T140000 -DTEND;TZID=Europe/Brussels:20131218T160000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.UD2.208 -DESCRIPTION: Professeur: \n Assistant Gr Sciences -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131219T080000 -DTEND;TZID=Europe/Brussels:20131219T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109 -DESCRIPTION: Professeur: Lucy, Gillian \n Assistant Rattrapage Mme Lucy -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130926T100000 -DTEND;TZID=Europe/Brussels:20130926T130000 -SUMMARY: Langages de programmation 2 -LOCATION: P.FORUM.H -DESCRIPTION: Professeur: Berten, Vandy, Roggeman, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131003T100000 -DTEND;TZID=Europe/Brussels:20131003T130000 -SUMMARY: Langages de programmation 2 -LOCATION: P.FORUM.H -DESCRIPTION: Professeur: Berten, Vandy, Roggeman, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131010T100000 -DTEND;TZID=Europe/Brussels:20131010T130000 -SUMMARY: Langages de programmation 2 -LOCATION: P.FORUM.H -DESCRIPTION: Professeur: Berten, Vandy, Roggeman, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131017T100000 -DTEND;TZID=Europe/Brussels:20131017T130000 -SUMMARY: Langages de programmation 2 -LOCATION: P.FORUM.H -DESCRIPTION: Professeur: Berten, Vandy, Roggeman, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131024T100000 -DTEND;TZID=Europe/Brussels:20131024T130000 -SUMMARY: Langages de programmation 2 -LOCATION: P.FORUM.H -DESCRIPTION: Professeur: Berten, Vandy, Roggeman, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131114T140000 -DTEND;TZID=Europe/Brussels:20131114T160000 -SUMMARY: Systèmes d'exploitation -LOCATION: P.Salle PC NO 4 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131121T140000 -DTEND;TZID=Europe/Brussels:20131121T160000 -SUMMARY: Systèmes d'exploitation -LOCATION: P.Salle PC NO 4 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131128T140000 -DTEND;TZID=Europe/Brussels:20131128T160000 -SUMMARY: Systèmes d'exploitation -LOCATION: P.Salle PC NO 4 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131205T140000 -DTEND;TZID=Europe/Brussels:20131205T160000 -SUMMARY: Systèmes d'exploitation -LOCATION: P.Salle PC NO 4 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131212T140000 -DTEND;TZID=Europe/Brussels:20131212T160000 -SUMMARY: Systèmes d'exploitation -LOCATION: P.Salle PC NO 4 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130919T160000 -DTEND;TZID=Europe/Brussels:20130919T170000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131003T160000 -DTEND;TZID=Europe/Brussels:20131003T170000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131010T160000 -DTEND;TZID=Europe/Brussels:20131010T170000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131017T160000 -DTEND;TZID=Europe/Brussels:20131017T170000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131024T160000 -DTEND;TZID=Europe/Brussels:20131024T170000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131107T160000 -DTEND;TZID=Europe/Brussels:20131107T170000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131114T160000 -DTEND;TZID=Europe/Brussels:20131114T170000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131121T160000 -DTEND;TZID=Europe/Brussels:20131121T170000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131128T160000 -DTEND;TZID=Europe/Brussels:20131128T170000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131205T160000 -DTEND;TZID=Europe/Brussels:20131205T170000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131212T160000 -DTEND;TZID=Europe/Brussels:20131212T170000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130926T080000 -DTEND;TZID=Europe/Brussels:20130926T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.2.115, S.P1.1.111 -DESCRIPTION: Professeur: Maitland, Leah, Lucy, Gillian, Essex, Richard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131003T080000 -DTEND;TZID=Europe/Brussels:20131003T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.2.115, S.P1.1.111 -DESCRIPTION: Professeur: Maitland, Leah, Lucy, Gillian, Essex, Richard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131010T080000 -DTEND;TZID=Europe/Brussels:20131010T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.2.115, S.P1.1.111 -DESCRIPTION: Professeur: Maitland, Leah, Lucy, Gillian, Essex, Richard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131017T080000 -DTEND;TZID=Europe/Brussels:20131017T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.2.115, S.P1.1.111 -DESCRIPTION: Professeur: Maitland, Leah, Lucy, Gillian, Essex, Richard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131024T080000 -DTEND;TZID=Europe/Brussels:20131024T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.2.115, S.P1.1.111 -DESCRIPTION: Professeur: Maitland, Leah, Lucy, Gillian, Essex, Richard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131107T080000 -DTEND;TZID=Europe/Brussels:20131107T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.2.115, S.P1.1.111 -DESCRIPTION: Professeur: Maitland, Leah, Lucy, Gillian, Essex, Richard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131114T080000 -DTEND;TZID=Europe/Brussels:20131114T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.2.115, S.P1.1.111 -DESCRIPTION: Professeur: Maitland, Leah, Lucy, Gillian, Essex, Richard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131121T080000 -DTEND;TZID=Europe/Brussels:20131121T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.2.115, S.P1.1.111 -DESCRIPTION: Professeur: Maitland, Leah, Lucy, Gillian, Essex, Richard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131128T080000 -DTEND;TZID=Europe/Brussels:20131128T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.2.115, S.P1.1.111 -DESCRIPTION: Professeur: Maitland, Leah, Lucy, Gillian, Essex, Richard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131205T080000 -DTEND;TZID=Europe/Brussels:20131205T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.2.115, S.P1.1.111 -DESCRIPTION: Professeur: Maitland, Leah, Lucy, Gillian, Essex, Richard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131212T080000 -DTEND;TZID=Europe/Brussels:20131212T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.2.115, S.P1.1.111 -DESCRIPTION: Professeur: Maitland, Leah, Lucy, Gillian, Essex, Richard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130919T100000 -DTEND;TZID=Europe/Brussels:20130919T130000 -SUMMARY: Langages de programmation 2 -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Roggeman, Yves, Berten, Vandy \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131107T100000 -DTEND;TZID=Europe/Brussels:20131107T130000 -SUMMARY: Langages de programmation 2 -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Roggeman, Yves, Berten, Vandy \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131114T100000 -DTEND;TZID=Europe/Brussels:20131114T130000 -SUMMARY: Langages de programmation 2 -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Roggeman, Yves, Berten, Vandy \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131121T100000 -DTEND;TZID=Europe/Brussels:20131121T130000 -SUMMARY: Langages de programmation 2 -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Roggeman, Yves, Berten, Vandy \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131128T100000 -DTEND;TZID=Europe/Brussels:20131128T130000 -SUMMARY: Langages de programmation 2 -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Roggeman, Yves, Berten, Vandy \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131205T100000 -DTEND;TZID=Europe/Brussels:20131205T130000 -SUMMARY: Langages de programmation 2 -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Roggeman, Yves, Berten, Vandy \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131212T100000 -DTEND;TZID=Europe/Brussels:20131212T130000 -SUMMARY: Langages de programmation 2 -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Roggeman, Yves, Berten, Vandy \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130926T160000 -DTEND;TZID=Europe/Brussels:20130926T170000 -SUMMARY: Calcul des probabilités et statistiques -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130919T170000 -DTEND;TZID=Europe/Brussels:20130919T190000 -SUMMARY: Analyse et méthodes -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: Hernalsteen, Christian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130926T170000 -DTEND;TZID=Europe/Brussels:20130926T190000 -SUMMARY: Analyse et méthodes -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: Hernalsteen, Christian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131003T170000 -DTEND;TZID=Europe/Brussels:20131003T190000 -SUMMARY: Analyse et méthodes -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: Hernalsteen, Christian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131010T170000 -DTEND;TZID=Europe/Brussels:20131010T190000 -SUMMARY: Analyse et méthodes -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: Hernalsteen, Christian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131017T170000 -DTEND;TZID=Europe/Brussels:20131017T190000 -SUMMARY: Analyse et méthodes -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: Hernalsteen, Christian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131024T170000 -DTEND;TZID=Europe/Brussels:20131024T190000 -SUMMARY: Analyse et méthodes -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: Hernalsteen, Christian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131004T080000 -DTEND;TZID=Europe/Brussels:20131004T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.1.111 -DESCRIPTION: Professeur: Essex, Richard, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131011T080000 -DTEND;TZID=Europe/Brussels:20131011T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.1.111 -DESCRIPTION: Professeur: Essex, Richard, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131018T080000 -DTEND;TZID=Europe/Brussels:20131018T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.1.111 -DESCRIPTION: Professeur: Essex, Richard, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131025T080000 -DTEND;TZID=Europe/Brussels:20131025T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.1.111 -DESCRIPTION: Professeur: Essex, Richard, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131108T080000 -DTEND;TZID=Europe/Brussels:20131108T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.1.111 -DESCRIPTION: Professeur: Essex, Richard, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131115T080000 -DTEND;TZID=Europe/Brussels:20131115T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.1.111 -DESCRIPTION: Professeur: Essex, Richard, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131122T080000 -DTEND;TZID=Europe/Brussels:20131122T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.1.111 -DESCRIPTION: Professeur: Essex, Richard, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131129T080000 -DTEND;TZID=Europe/Brussels:20131129T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.1.111 -DESCRIPTION: Professeur: Essex, Richard, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131206T080000 -DTEND;TZID=Europe/Brussels:20131206T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.1.111 -DESCRIPTION: Professeur: Essex, Richard, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131213T080000 -DTEND;TZID=Europe/Brussels:20131213T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.P3.3.109, S.P1.1.111 -DESCRIPTION: Professeur: Essex, Richard, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131115T140000 -DTEND;TZID=Europe/Brussels:20131115T160000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.DC2.206 -DESCRIPTION: Professeur: \n Assistant Gr Sciences -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130920T080000 -DTEND;TZID=Europe/Brussels:20130920T100000 -SUMMARY: Anglais scientifique I -LOCATION: S.UD2.120 -DESCRIPTION: Professeur: Essex, Richard, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130920T100000 -DTEND;TZID=Europe/Brussels:20130920T120000 -SUMMARY: Algorithmique 2 -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: Fortz, Bernard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131004T100000 -DTEND;TZID=Europe/Brussels:20131004T120000 -SUMMARY: Algorithmique 2 -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: Fortz, Bernard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131011T100000 -DTEND;TZID=Europe/Brussels:20131011T120000 -SUMMARY: Algorithmique 2 -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: Fortz, Bernard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131018T100000 -DTEND;TZID=Europe/Brussels:20131018T120000 -SUMMARY: Algorithmique 2 -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: Fortz, Bernard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131025T100000 -DTEND;TZID=Europe/Brussels:20131025T120000 -SUMMARY: Algorithmique 2 -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: Fortz, Bernard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131108T100000 -DTEND;TZID=Europe/Brussels:20131108T120000 -SUMMARY: Algorithmique 2 -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: Fortz, Bernard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131115T100000 -DTEND;TZID=Europe/Brussels:20131115T120000 -SUMMARY: Algorithmique 2 -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: Fortz, Bernard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131122T100000 -DTEND;TZID=Europe/Brussels:20131122T120000 -SUMMARY: Algorithmique 2 -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: Fortz, Bernard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131129T100000 -DTEND;TZID=Europe/Brussels:20131129T120000 -SUMMARY: Algorithmique 2 -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: Fortz, Bernard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131206T100000 -DTEND;TZID=Europe/Brussels:20131206T120000 -SUMMARY: Algorithmique 2 -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: Fortz, Bernard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131213T100000 -DTEND;TZID=Europe/Brussels:20131213T120000 -SUMMARY: Algorithmique 2 -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: Fortz, Bernard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131220T100000 -DTEND;TZID=Europe/Brussels:20131220T120000 -SUMMARY: Algorithmique 2 -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: Fortz, Bernard \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131004T120000 -DTEND;TZID=Europe/Brussels:20131004T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.H2214 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131011T120000 -DTEND;TZID=Europe/Brussels:20131011T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.H2214 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131018T120000 -DTEND;TZID=Europe/Brussels:20131018T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.H2214 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131025T120000 -DTEND;TZID=Europe/Brussels:20131025T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.H2214 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131108T120000 -DTEND;TZID=Europe/Brussels:20131108T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.H2214 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131115T120000 -DTEND;TZID=Europe/Brussels:20131115T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.H2214 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131122T120000 -DTEND;TZID=Europe/Brussels:20131122T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.H2214 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131129T120000 -DTEND;TZID=Europe/Brussels:20131129T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.H2214 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131206T120000 -DTEND;TZID=Europe/Brussels:20131206T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.H2214 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131213T120000 -DTEND;TZID=Europe/Brussels:20131213T140000 -SUMMARY: Introduction à la microéconomie -LOCATION: S.H2214 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -END:VCALENDAR diff --git a/tests/gehol/BA3.ics b/tests/gehol/BA3.ics deleted file mode 100644 index da54c032..00000000 --- a/tests/gehol/BA3.ics +++ /dev/null @@ -1,1951 +0,0 @@ -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//hacksw/handcal//NONSGML v1.0//EN - -BEGIN:VTIMEZONE -TZID:Europe/Brussels -X-LIC-LOCATION:Europe/Brussels -BEGIN:STANDARD -DTSTART:20111030T020000 -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -TZNAME:CET -END:STANDARD -BEGIN:DAYLIGHT -DTSTART:20120325T030000 -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -TZNAME:CEST -END:DAYLIGHT -END:VTIMEZONE - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131007T100000 -DTEND;TZID=Europe/Brussels:20131007T130000 -SUMMARY: Modélisation et simulation -LOCATION: S.Salle.Baugniet (S.S.01.326) -DESCRIPTION: Professeur: Bontempi, Gianluca \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131202T100000 -DTEND;TZID=Europe/Brussels:20131202T130000 -SUMMARY: Modélisation et simulation -LOCATION: S.Salle.Baugniet (S.S.01.326) -DESCRIPTION: Professeur: Bontempi, Gianluca \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130916T100000 -DTEND;TZID=Europe/Brussels:20130916T130000 -SUMMARY: Modélisation et simulation -LOCATION: -DESCRIPTION: Professeur: Bontempi, Gianluca \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131104T100000 -DTEND;TZID=Europe/Brussels:20131104T130000 -SUMMARY: Modélisation et simulation -LOCATION: S.AW1.120 -DESCRIPTION: Professeur: Bontempi, Gianluca \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131118T100000 -DTEND;TZID=Europe/Brussels:20131118T130000 -SUMMARY: Modélisation et simulation -LOCATION: S.AW1.120 -DESCRIPTION: Professeur: Bontempi, Gianluca \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131125T100000 -DTEND;TZID=Europe/Brussels:20131125T130000 -SUMMARY: Modélisation et simulation -LOCATION: S.AW1.120 -DESCRIPTION: Professeur: Bontempi, Gianluca \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131209T100000 -DTEND;TZID=Europe/Brussels:20131209T130000 -SUMMARY: Modélisation et simulation -LOCATION: S.AW1.120 -DESCRIPTION: Professeur: Bontempi, Gianluca \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131216T100000 -DTEND;TZID=Europe/Brussels:20131216T130000 -SUMMARY: Modélisation et simulation -LOCATION: S.AW1.120 -DESCRIPTION: Professeur: Bontempi, Gianluca \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130923T160000 -DTEND;TZID=Europe/Brussels:20130923T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.E -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130930T160000 -DTEND;TZID=Europe/Brussels:20130930T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.E -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131007T160000 -DTEND;TZID=Europe/Brussels:20131007T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.E -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131014T160000 -DTEND;TZID=Europe/Brussels:20131014T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.E -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131021T160000 -DTEND;TZID=Europe/Brussels:20131021T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.E -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131028T160000 -DTEND;TZID=Europe/Brussels:20131028T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.E -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131104T160000 -DTEND;TZID=Europe/Brussels:20131104T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.E -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131118T160000 -DTEND;TZID=Europe/Brussels:20131118T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.E -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131125T160000 -DTEND;TZID=Europe/Brussels:20131125T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.E -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131202T160000 -DTEND;TZID=Europe/Brussels:20131202T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.E -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131021T100000 -DTEND;TZID=Europe/Brussels:20131021T130000 -SUMMARY: Modélisation et simulation -LOCATION: S.UA4.222 -DESCRIPTION: Professeur: Bontempi, Gianluca \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130916T160000 -DTEND;TZID=Europe/Brussels:20130916T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: Kruys, Véronique, Vandenbranden, Michel \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130916T090000 -DTEND;TZID=Europe/Brussels:20130916T100000 -SUMMARY: Anglais scientifique II -LOCATION: S.P1.1.111 -DESCRIPTION: Professeur: Essex, Richard \n Assistant Introduction au cours -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131014T100000 -DTEND;TZID=Europe/Brussels:20131014T130000 -SUMMARY: Modélisation et simulation -LOCATION: S.R42.4.502 -DESCRIPTION: Professeur: Bontempi, Gianluca \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130916T160000 -DTEND;TZID=Europe/Brussels:20130916T180000 -SUMMARY: Circuits logiques et numériques -LOCATION: S.UB4.132 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130923T160000 -DTEND;TZID=Europe/Brussels:20130923T180000 -SUMMARY: Circuits logiques et numériques -LOCATION: S.UB4.132 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130930T160000 -DTEND;TZID=Europe/Brussels:20130930T180000 -SUMMARY: Circuits logiques et numériques -LOCATION: S.UB4.132 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131007T160000 -DTEND;TZID=Europe/Brussels:20131007T180000 -SUMMARY: Circuits logiques et numériques -LOCATION: S.UB4.132 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131014T160000 -DTEND;TZID=Europe/Brussels:20131014T180000 -SUMMARY: Circuits logiques et numériques -LOCATION: S.UB4.132 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131021T160000 -DTEND;TZID=Europe/Brussels:20131021T180000 -SUMMARY: Circuits logiques et numériques -LOCATION: S.UB4.132 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131028T160000 -DTEND;TZID=Europe/Brussels:20131028T180000 -SUMMARY: Circuits logiques et numériques -LOCATION: S.UB4.132 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131104T160000 -DTEND;TZID=Europe/Brussels:20131104T180000 -SUMMARY: Circuits logiques et numériques -LOCATION: S.UB4.132 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131118T160000 -DTEND;TZID=Europe/Brussels:20131118T180000 -SUMMARY: Circuits logiques et numériques -LOCATION: S.UB4.132 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131125T160000 -DTEND;TZID=Europe/Brussels:20131125T180000 -SUMMARY: Circuits logiques et numériques -LOCATION: S.UB4.132 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131202T160000 -DTEND;TZID=Europe/Brussels:20131202T180000 -SUMMARY: Circuits logiques et numériques -LOCATION: S.UB4.132 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131209T160000 -DTEND;TZID=Europe/Brussels:20131209T180000 -SUMMARY: Circuits logiques et numériques -LOCATION: S.UB4.132 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130923T080000 -DTEND;TZID=Europe/Brussels:20130923T100000 -SUMMARY: Anglais scientifique II -LOCATION: S.P3.3.211, S.P1.3.214, S.P1.3.116, S.P1.1.111 -DESCRIPTION: Professeur: Maitland, Leah, Essex, Richard, Ellefson, Eugene, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130930T080000 -DTEND;TZID=Europe/Brussels:20130930T100000 -SUMMARY: Anglais scientifique II -LOCATION: S.P3.3.211, S.P1.3.214, S.P1.3.116, S.P1.1.111 -DESCRIPTION: Professeur: Maitland, Leah, Essex, Richard, Ellefson, Eugene, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131007T080000 -DTEND;TZID=Europe/Brussels:20131007T100000 -SUMMARY: Anglais scientifique II -LOCATION: S.P3.3.211, S.P1.3.214, S.P1.3.116, S.P1.1.111 -DESCRIPTION: Professeur: Maitland, Leah, Essex, Richard, Ellefson, Eugene, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131014T080000 -DTEND;TZID=Europe/Brussels:20131014T100000 -SUMMARY: Anglais scientifique II -LOCATION: S.P3.3.211, S.P1.3.214, S.P1.3.116, S.P1.1.111 -DESCRIPTION: Professeur: Maitland, Leah, Essex, Richard, Ellefson, Eugene, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131021T080000 -DTEND;TZID=Europe/Brussels:20131021T100000 -SUMMARY: Anglais scientifique II -LOCATION: S.P3.3.211, S.P1.3.214, S.P1.3.116, S.P1.1.111 -DESCRIPTION: Professeur: Maitland, Leah, Essex, Richard, Ellefson, Eugene, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131104T080000 -DTEND;TZID=Europe/Brussels:20131104T100000 -SUMMARY: Anglais scientifique II -LOCATION: S.P3.3.211, S.P1.3.214, S.P1.3.116, S.P1.1.111 -DESCRIPTION: Professeur: Maitland, Leah, Essex, Richard, Ellefson, Eugene, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131118T080000 -DTEND;TZID=Europe/Brussels:20131118T100000 -SUMMARY: Anglais scientifique II -LOCATION: S.P3.3.211, S.P1.3.214, S.P1.3.116, S.P1.1.111 -DESCRIPTION: Professeur: Maitland, Leah, Essex, Richard, Ellefson, Eugene, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131125T080000 -DTEND;TZID=Europe/Brussels:20131125T100000 -SUMMARY: Anglais scientifique II -LOCATION: S.P3.3.211, S.P1.3.214, S.P1.3.116, S.P1.1.111 -DESCRIPTION: Professeur: Maitland, Leah, Essex, Richard, Ellefson, Eugene, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131202T080000 -DTEND;TZID=Europe/Brussels:20131202T100000 -SUMMARY: Anglais scientifique II -LOCATION: S.P3.3.211, S.P1.3.214, S.P1.3.116, S.P1.1.111 -DESCRIPTION: Professeur: Maitland, Leah, Essex, Richard, Ellefson, Eugene, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131209T080000 -DTEND;TZID=Europe/Brussels:20131209T100000 -SUMMARY: Anglais scientifique II -LOCATION: S.P3.3.211, S.P1.3.214, S.P1.3.116, S.P1.1.111 -DESCRIPTION: Professeur: Maitland, Leah, Essex, Richard, Ellefson, Eugene, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130923T100000 -DTEND;TZID=Europe/Brussels:20130923T130000 -SUMMARY: Modélisation et simulation -LOCATION: P.NO8 rotule -DESCRIPTION: Professeur: Bontempi, Gianluca \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130930T100000 -DTEND;TZID=Europe/Brussels:20130930T130000 -SUMMARY: Modélisation et simulation -LOCATION: P.NO8 rotule -DESCRIPTION: Professeur: Bontempi, Gianluca \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130916T140000 -DTEND;TZID=Europe/Brussels:20130916T160000 -SUMMARY: Circuits logiques et numériques -LOCATION: S.UB4.132 -DESCRIPTION: Professeur: Milojevic, Dragomir \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130923T140000 -DTEND;TZID=Europe/Brussels:20130923T160000 -SUMMARY: Circuits logiques et numériques -LOCATION: S.UB4.132 -DESCRIPTION: Professeur: Milojevic, Dragomir \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130930T140000 -DTEND;TZID=Europe/Brussels:20130930T160000 -SUMMARY: Circuits logiques et numériques -LOCATION: S.UB4.132 -DESCRIPTION: Professeur: Milojevic, Dragomir \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131007T140000 -DTEND;TZID=Europe/Brussels:20131007T160000 -SUMMARY: Circuits logiques et numériques -LOCATION: S.UB4.132 -DESCRIPTION: Professeur: Milojevic, Dragomir \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131014T140000 -DTEND;TZID=Europe/Brussels:20131014T160000 -SUMMARY: Circuits logiques et numériques -LOCATION: S.UB4.132 -DESCRIPTION: Professeur: Milojevic, Dragomir \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131021T140000 -DTEND;TZID=Europe/Brussels:20131021T160000 -SUMMARY: Circuits logiques et numériques -LOCATION: S.UB4.132 -DESCRIPTION: Professeur: Milojevic, Dragomir \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131028T140000 -DTEND;TZID=Europe/Brussels:20131028T160000 -SUMMARY: Circuits logiques et numériques -LOCATION: S.UB4.132 -DESCRIPTION: Professeur: Milojevic, Dragomir \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131104T140000 -DTEND;TZID=Europe/Brussels:20131104T160000 -SUMMARY: Circuits logiques et numériques -LOCATION: S.UB4.132 -DESCRIPTION: Professeur: Milojevic, Dragomir \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131118T140000 -DTEND;TZID=Europe/Brussels:20131118T160000 -SUMMARY: Circuits logiques et numériques -LOCATION: S.UB4.132 -DESCRIPTION: Professeur: Milojevic, Dragomir \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131125T140000 -DTEND;TZID=Europe/Brussels:20131125T160000 -SUMMARY: Circuits logiques et numériques -LOCATION: S.UB4.132 -DESCRIPTION: Professeur: Milojevic, Dragomir \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131202T140000 -DTEND;TZID=Europe/Brussels:20131202T160000 -SUMMARY: Circuits logiques et numériques -LOCATION: S.UB4.132 -DESCRIPTION: Professeur: Milojevic, Dragomir \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131209T140000 -DTEND;TZID=Europe/Brussels:20131209T160000 -SUMMARY: Circuits logiques et numériques -LOCATION: S.UB4.132 -DESCRIPTION: Professeur: Milojevic, Dragomir \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130923T160000 -DTEND;TZID=Europe/Brussels:20130923T180000 -SUMMARY: Anglais scientifique II -LOCATION: S.P1.3.214, S.P1.2.214 -DESCRIPTION: Professeur: Essex, Richard, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130930T160000 -DTEND;TZID=Europe/Brussels:20130930T180000 -SUMMARY: Anglais scientifique II -LOCATION: S.P1.3.214, S.P1.2.214 -DESCRIPTION: Professeur: Essex, Richard, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131007T160000 -DTEND;TZID=Europe/Brussels:20131007T180000 -SUMMARY: Anglais scientifique II -LOCATION: S.P1.3.214, S.P1.2.214 -DESCRIPTION: Professeur: Essex, Richard, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131014T160000 -DTEND;TZID=Europe/Brussels:20131014T180000 -SUMMARY: Anglais scientifique II -LOCATION: S.P1.3.214, S.P1.2.214 -DESCRIPTION: Professeur: Essex, Richard, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131021T160000 -DTEND;TZID=Europe/Brussels:20131021T180000 -SUMMARY: Anglais scientifique II -LOCATION: S.P1.3.214, S.P1.2.214 -DESCRIPTION: Professeur: Essex, Richard, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131104T160000 -DTEND;TZID=Europe/Brussels:20131104T180000 -SUMMARY: Anglais scientifique II -LOCATION: S.P1.3.214, S.P1.2.214 -DESCRIPTION: Professeur: Essex, Richard, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131118T160000 -DTEND;TZID=Europe/Brussels:20131118T180000 -SUMMARY: Anglais scientifique II -LOCATION: S.P1.3.214, S.P1.2.214 -DESCRIPTION: Professeur: Essex, Richard, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131125T160000 -DTEND;TZID=Europe/Brussels:20131125T180000 -SUMMARY: Anglais scientifique II -LOCATION: S.P1.3.214, S.P1.2.214 -DESCRIPTION: Professeur: Essex, Richard, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131202T160000 -DTEND;TZID=Europe/Brussels:20131202T180000 -SUMMARY: Anglais scientifique II -LOCATION: S.P1.3.214, S.P1.2.214 -DESCRIPTION: Professeur: Essex, Richard, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131209T160000 -DTEND;TZID=Europe/Brussels:20131209T180000 -SUMMARY: Anglais scientifique II -LOCATION: S.P1.3.214, S.P1.2.214 -DESCRIPTION: Professeur: Essex, Richard, Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130924T080000 -DTEND;TZID=Europe/Brussels:20130924T100000 -SUMMARY: Anglais scientifique II -LOCATION: S.P1.3.214 -DESCRIPTION: Professeur: Ellefson, Eugene \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131001T080000 -DTEND;TZID=Europe/Brussels:20131001T100000 -SUMMARY: Anglais scientifique II -LOCATION: S.P1.3.214 -DESCRIPTION: Professeur: Ellefson, Eugene \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131008T080000 -DTEND;TZID=Europe/Brussels:20131008T100000 -SUMMARY: Anglais scientifique II -LOCATION: S.P1.3.214 -DESCRIPTION: Professeur: Ellefson, Eugene \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131015T080000 -DTEND;TZID=Europe/Brussels:20131015T100000 -SUMMARY: Anglais scientifique II -LOCATION: S.P1.3.214 -DESCRIPTION: Professeur: Ellefson, Eugene \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131022T080000 -DTEND;TZID=Europe/Brussels:20131022T100000 -SUMMARY: Anglais scientifique II -LOCATION: S.P1.3.214 -DESCRIPTION: Professeur: Ellefson, Eugene \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131029T080000 -DTEND;TZID=Europe/Brussels:20131029T100000 -SUMMARY: Anglais scientifique II -LOCATION: S.P1.3.214 -DESCRIPTION: Professeur: Ellefson, Eugene \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131105T080000 -DTEND;TZID=Europe/Brussels:20131105T100000 -SUMMARY: Anglais scientifique II -LOCATION: S.P1.3.214 -DESCRIPTION: Professeur: Ellefson, Eugene \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131112T080000 -DTEND;TZID=Europe/Brussels:20131112T100000 -SUMMARY: Anglais scientifique II -LOCATION: S.P1.3.214 -DESCRIPTION: Professeur: Ellefson, Eugene \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131119T080000 -DTEND;TZID=Europe/Brussels:20131119T100000 -SUMMARY: Anglais scientifique II -LOCATION: S.P1.3.214 -DESCRIPTION: Professeur: Ellefson, Eugene \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131126T080000 -DTEND;TZID=Europe/Brussels:20131126T100000 -SUMMARY: Anglais scientifique II -LOCATION: S.P1.3.214 -DESCRIPTION: Professeur: Ellefson, Eugene \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131203T080000 -DTEND;TZID=Europe/Brussels:20131203T100000 -SUMMARY: Anglais scientifique II -LOCATION: S.P1.3.214 -DESCRIPTION: Professeur: Ellefson, Eugene \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131210T080000 -DTEND;TZID=Europe/Brussels:20131210T100000 -SUMMARY: Anglais scientifique II -LOCATION: S.P1.3.214 -DESCRIPTION: Professeur: Ellefson, Eugene \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130924T100000 -DTEND;TZID=Europe/Brussels:20130924T130000 -SUMMARY: Réseaux -LOCATION: P.NO8 rotule -DESCRIPTION: Professeur: Leduc, Guy \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131001T100000 -DTEND;TZID=Europe/Brussels:20131001T130000 -SUMMARY: Réseaux -LOCATION: P.NO8 rotule -DESCRIPTION: Professeur: Leduc, Guy \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131029T130000 -DTEND;TZID=Europe/Brussels:20131029T160000 -SUMMARY: Biologie moléculaire et cellulaire -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Gueydan, Cyril \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131105T130000 -DTEND;TZID=Europe/Brussels:20131105T160000 -SUMMARY: Biologie moléculaire et cellulaire -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Gueydan, Cyril \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131112T130000 -DTEND;TZID=Europe/Brussels:20131112T160000 -SUMMARY: Biologie moléculaire et cellulaire -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Gueydan, Cyril \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131119T130000 -DTEND;TZID=Europe/Brussels:20131119T160000 -SUMMARY: Biologie moléculaire et cellulaire -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Gueydan, Cyril \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131126T130000 -DTEND;TZID=Europe/Brussels:20131126T160000 -SUMMARY: Biologie moléculaire et cellulaire -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Gueydan, Cyril \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131203T130000 -DTEND;TZID=Europe/Brussels:20131203T160000 -SUMMARY: Biologie moléculaire et cellulaire -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Gueydan, Cyril \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131210T130000 -DTEND;TZID=Europe/Brussels:20131210T160000 -SUMMARY: Biologie moléculaire et cellulaire -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Gueydan, Cyril \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130924T160000 -DTEND;TZID=Europe/Brussels:20130924T180000 -SUMMARY: Anglais scientifique II -LOCATION: S.P3.3.109 -DESCRIPTION: Professeur: Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131001T160000 -DTEND;TZID=Europe/Brussels:20131001T180000 -SUMMARY: Anglais scientifique II -LOCATION: S.P3.3.109 -DESCRIPTION: Professeur: Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131008T160000 -DTEND;TZID=Europe/Brussels:20131008T180000 -SUMMARY: Anglais scientifique II -LOCATION: S.P3.3.109 -DESCRIPTION: Professeur: Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131015T160000 -DTEND;TZID=Europe/Brussels:20131015T180000 -SUMMARY: Anglais scientifique II -LOCATION: S.P3.3.109 -DESCRIPTION: Professeur: Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131022T160000 -DTEND;TZID=Europe/Brussels:20131022T180000 -SUMMARY: Anglais scientifique II -LOCATION: S.P3.3.109 -DESCRIPTION: Professeur: Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131029T160000 -DTEND;TZID=Europe/Brussels:20131029T180000 -SUMMARY: Anglais scientifique II -LOCATION: S.P3.3.109 -DESCRIPTION: Professeur: Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131105T160000 -DTEND;TZID=Europe/Brussels:20131105T180000 -SUMMARY: Anglais scientifique II -LOCATION: S.P3.3.109 -DESCRIPTION: Professeur: Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131112T160000 -DTEND;TZID=Europe/Brussels:20131112T180000 -SUMMARY: Anglais scientifique II -LOCATION: S.P3.3.109 -DESCRIPTION: Professeur: Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131119T160000 -DTEND;TZID=Europe/Brussels:20131119T180000 -SUMMARY: Anglais scientifique II -LOCATION: S.P3.3.109 -DESCRIPTION: Professeur: Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131126T160000 -DTEND;TZID=Europe/Brussels:20131126T180000 -SUMMARY: Anglais scientifique II -LOCATION: S.P3.3.109 -DESCRIPTION: Professeur: Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131203T160000 -DTEND;TZID=Europe/Brussels:20131203T180000 -SUMMARY: Anglais scientifique II -LOCATION: S.P3.3.109 -DESCRIPTION: Professeur: Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131210T160000 -DTEND;TZID=Europe/Brussels:20131210T180000 -SUMMARY: Anglais scientifique II -LOCATION: S.P3.3.109 -DESCRIPTION: Professeur: Lucy, Gillian \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130917T080000 -DTEND;TZID=Europe/Brussels:20130917T100000 -SUMMARY: Electronique appliquée -LOCATION: S.UB5.230 -DESCRIPTION: Professeur: Robert, Frédéric \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130924T080000 -DTEND;TZID=Europe/Brussels:20130924T100000 -SUMMARY: Electronique appliquée -LOCATION: S.UB5.230 -DESCRIPTION: Professeur: Robert, Frédéric \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131001T080000 -DTEND;TZID=Europe/Brussels:20131001T100000 -SUMMARY: Electronique appliquée -LOCATION: S.UB5.230 -DESCRIPTION: Professeur: Robert, Frédéric \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131008T080000 -DTEND;TZID=Europe/Brussels:20131008T100000 -SUMMARY: Electronique appliquée -LOCATION: S.UB5.230 -DESCRIPTION: Professeur: Robert, Frédéric \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131015T080000 -DTEND;TZID=Europe/Brussels:20131015T100000 -SUMMARY: Electronique appliquée -LOCATION: S.UB5.230 -DESCRIPTION: Professeur: Robert, Frédéric \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131022T080000 -DTEND;TZID=Europe/Brussels:20131022T100000 -SUMMARY: Electronique appliquée -LOCATION: S.UB5.230 -DESCRIPTION: Professeur: Robert, Frédéric \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131105T080000 -DTEND;TZID=Europe/Brussels:20131105T100000 -SUMMARY: Electronique appliquée -LOCATION: S.UB5.230 -DESCRIPTION: Professeur: Robert, Frédéric \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131119T080000 -DTEND;TZID=Europe/Brussels:20131119T100000 -SUMMARY: Electronique appliquée -LOCATION: S.UB5.230 -DESCRIPTION: Professeur: Robert, Frédéric \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131126T080000 -DTEND;TZID=Europe/Brussels:20131126T100000 -SUMMARY: Electronique appliquée -LOCATION: S.UB5.230 -DESCRIPTION: Professeur: Robert, Frédéric \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131203T080000 -DTEND;TZID=Europe/Brussels:20131203T100000 -SUMMARY: Electronique appliquée -LOCATION: S.UB5.230 -DESCRIPTION: Professeur: Robert, Frédéric \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131210T080000 -DTEND;TZID=Europe/Brussels:20131210T100000 -SUMMARY: Electronique appliquée -LOCATION: S.UB5.230 -DESCRIPTION: Professeur: Robert, Frédéric \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131217T080000 -DTEND;TZID=Europe/Brussels:20131217T100000 -SUMMARY: Electronique appliquée -LOCATION: S.UB5.230 -DESCRIPTION: Professeur: Robert, Frédéric \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131029T100000 -DTEND;TZID=Europe/Brussels:20131029T120000 -SUMMARY: Réseaux -LOCATION: P.OF.2070 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131112T100000 -DTEND;TZID=Europe/Brussels:20131112T120000 -SUMMARY: Réseaux -LOCATION: P.OF.2070 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131119T100000 -DTEND;TZID=Europe/Brussels:20131119T120000 -SUMMARY: Réseaux -LOCATION: P.OF.2070 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131126T100000 -DTEND;TZID=Europe/Brussels:20131126T120000 -SUMMARY: Réseaux -LOCATION: P.OF.2070 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131203T100000 -DTEND;TZID=Europe/Brussels:20131203T120000 -SUMMARY: Réseaux -LOCATION: P.OF.2070 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131210T100000 -DTEND;TZID=Europe/Brussels:20131210T120000 -SUMMARY: Réseaux -LOCATION: P.OF.2070 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130917T160000 -DTEND;TZID=Europe/Brussels:20130917T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130924T160000 -DTEND;TZID=Europe/Brussels:20130924T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131001T160000 -DTEND;TZID=Europe/Brussels:20131001T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131008T160000 -DTEND;TZID=Europe/Brussels:20131008T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131015T160000 -DTEND;TZID=Europe/Brussels:20131015T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131022T160000 -DTEND;TZID=Europe/Brussels:20131022T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131029T160000 -DTEND;TZID=Europe/Brussels:20131029T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131105T160000 -DTEND;TZID=Europe/Brussels:20131105T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131112T160000 -DTEND;TZID=Europe/Brussels:20131112T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131119T160000 -DTEND;TZID=Europe/Brussels:20131119T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131126T160000 -DTEND;TZID=Europe/Brussels:20131126T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131203T160000 -DTEND;TZID=Europe/Brussels:20131203T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131210T160000 -DTEND;TZID=Europe/Brussels:20131210T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131016T140000 -DTEND;TZID=Europe/Brussels:20131016T160000 -SUMMARY: Mathématiques discrètes -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131023T140000 -DTEND;TZID=Europe/Brussels:20131023T160000 -SUMMARY: Mathématiques discrètes -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131030T140000 -DTEND;TZID=Europe/Brussels:20131030T160000 -SUMMARY: Mathématiques discrètes -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131106T140000 -DTEND;TZID=Europe/Brussels:20131106T160000 -SUMMARY: Mathématiques discrètes -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130925T160000 -DTEND;TZID=Europe/Brussels:20130925T180000 -SUMMARY: Electronique appliquée -LOCATION: -DESCRIPTION: Professeur: \n Assistant local S.UA5.217 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131002T160000 -DTEND;TZID=Europe/Brussels:20131002T180000 -SUMMARY: Electronique appliquée -LOCATION: -DESCRIPTION: Professeur: \n Assistant local S.UA5.217 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131009T160000 -DTEND;TZID=Europe/Brussels:20131009T180000 -SUMMARY: Electronique appliquée -LOCATION: -DESCRIPTION: Professeur: \n Assistant local S.UA5.217 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131016T160000 -DTEND;TZID=Europe/Brussels:20131016T180000 -SUMMARY: Electronique appliquée -LOCATION: -DESCRIPTION: Professeur: \n Assistant local S.UA5.217 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131023T160000 -DTEND;TZID=Europe/Brussels:20131023T180000 -SUMMARY: Electronique appliquée -LOCATION: -DESCRIPTION: Professeur: \n Assistant local S.UA5.217 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131030T160000 -DTEND;TZID=Europe/Brussels:20131030T180000 -SUMMARY: Electronique appliquée -LOCATION: -DESCRIPTION: Professeur: \n Assistant local S.UA5.217 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131106T160000 -DTEND;TZID=Europe/Brussels:20131106T180000 -SUMMARY: Electronique appliquée -LOCATION: -DESCRIPTION: Professeur: \n Assistant local S.UA5.217 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131113T160000 -DTEND;TZID=Europe/Brussels:20131113T180000 -SUMMARY: Electronique appliquée -LOCATION: -DESCRIPTION: Professeur: \n Assistant local S.UA5.217 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131127T160000 -DTEND;TZID=Europe/Brussels:20131127T180000 -SUMMARY: Electronique appliquée -LOCATION: -DESCRIPTION: Professeur: \n Assistant local S.UA5.217 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131204T160000 -DTEND;TZID=Europe/Brussels:20131204T180000 -SUMMARY: Electronique appliquée -LOCATION: -DESCRIPTION: Professeur: \n Assistant local S.UA5.217 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131211T160000 -DTEND;TZID=Europe/Brussels:20131211T180000 -SUMMARY: Electronique appliquée -LOCATION: -DESCRIPTION: Professeur: \n Assistant local S.UA5.217 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130918T140000 -DTEND;TZID=Europe/Brussels:20130918T160000 -SUMMARY: Mathématiques discrètes -LOCATION: P.FORUM.E -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131113T140000 -DTEND;TZID=Europe/Brussels:20131113T160000 -SUMMARY: Mathématiques discrètes -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130925T140000 -DTEND;TZID=Europe/Brussels:20130925T160000 -SUMMARY: Mathématiques discrètes -LOCATION: P.2NO.607 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131009T140000 -DTEND;TZID=Europe/Brussels:20131009T160000 -SUMMARY: Mathématiques discrètes -LOCATION: P.2NO.607 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131127T140000 -DTEND;TZID=Europe/Brussels:20131127T160000 -SUMMARY: Mathématiques discrètes -LOCATION: P.OF.2070 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131204T140000 -DTEND;TZID=Europe/Brussels:20131204T160000 -SUMMARY: Mathématiques discrètes -LOCATION: P.OF.2070 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131211T140000 -DTEND;TZID=Europe/Brussels:20131211T160000 -SUMMARY: Mathématiques discrètes -LOCATION: P.OF.2070 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131218T140000 -DTEND;TZID=Europe/Brussels:20131218T160000 -SUMMARY: Mathématiques discrètes -LOCATION: P.OF.2070 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131219T100000 -DTEND;TZID=Europe/Brussels:20131219T120000 -SUMMARY: Mathématiques discrètes -LOCATION: P.A2.122 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131024T140000 -DTEND;TZID=Europe/Brussels:20131024T160000 -SUMMARY: Modélisation et simulation -LOCATION: P.OF.2070 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131121T140000 -DTEND;TZID=Europe/Brussels:20131121T160000 -SUMMARY: Modélisation et simulation -LOCATION: P.2NO.707 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131121T080000 -DTEND;TZID=Europe/Brussels:20131121T100000 -SUMMARY: Génie logiciel et gestion de projets -LOCATION: P.FORUM.E -DESCRIPTION: Professeur: Van Der Straeten, Ragnhild \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131107T140000 -DTEND;TZID=Europe/Brussels:20131107T160000 -SUMMARY: Modélisation et simulation -LOCATION: P.2NO4.008.PC -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131114T140000 -DTEND;TZID=Europe/Brussels:20131114T160000 -SUMMARY: Modélisation et simulation -LOCATION: P.2NO4.008.PC -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131024T080000 -DTEND;TZID=Europe/Brussels:20131024T100000 -SUMMARY: Génie logiciel et gestion de projets -LOCATION: S.DC2.223 -DESCRIPTION: Professeur: Van Der Straeten, Ragnhild \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130919T100000 -DTEND;TZID=Europe/Brussels:20130919T120000 -SUMMARY: Génie logiciel et gestion de projets -LOCATION: S.DC2.223 -DESCRIPTION: Professeur: Van Der Straeten, Ragnhild \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130926T100000 -DTEND;TZID=Europe/Brussels:20130926T120000 -SUMMARY: Génie logiciel et gestion de projets -LOCATION: S.DC2.223 -DESCRIPTION: Professeur: Van Der Straeten, Ragnhild \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131003T100000 -DTEND;TZID=Europe/Brussels:20131003T120000 -SUMMARY: Génie logiciel et gestion de projets -LOCATION: S.DC2.223 -DESCRIPTION: Professeur: Van Der Straeten, Ragnhild \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131010T100000 -DTEND;TZID=Europe/Brussels:20131010T120000 -SUMMARY: Génie logiciel et gestion de projets -LOCATION: S.DC2.223 -DESCRIPTION: Professeur: Van Der Straeten, Ragnhild \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131017T100000 -DTEND;TZID=Europe/Brussels:20131017T120000 -SUMMARY: Génie logiciel et gestion de projets -LOCATION: S.DC2.223 -DESCRIPTION: Professeur: Van Der Straeten, Ragnhild \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131024T100000 -DTEND;TZID=Europe/Brussels:20131024T120000 -SUMMARY: Génie logiciel et gestion de projets -LOCATION: S.DC2.223 -DESCRIPTION: Professeur: Van Der Straeten, Ragnhild \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131003T140000 -DTEND;TZID=Europe/Brussels:20131003T160000 -SUMMARY: Modélisation et simulation -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131010T140000 -DTEND;TZID=Europe/Brussels:20131010T160000 -SUMMARY: Modélisation et simulation -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131017T140000 -DTEND;TZID=Europe/Brussels:20131017T160000 -SUMMARY: Modélisation et simulation -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131128T080000 -DTEND;TZID=Europe/Brussels:20131128T100000 -SUMMARY: Modélisation et simulation -LOCATION: P.FORUM.H -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131205T080000 -DTEND;TZID=Europe/Brussels:20131205T100000 -SUMMARY: Modélisation et simulation -LOCATION: P.FORUM.H -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131212T080000 -DTEND;TZID=Europe/Brussels:20131212T100000 -SUMMARY: Modélisation et simulation -LOCATION: P.FORUM.H -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131107T100000 -DTEND;TZID=Europe/Brussels:20131107T120000 -SUMMARY: Génie logiciel et gestion de projets -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Van Der Straeten, Ragnhild \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131114T100000 -DTEND;TZID=Europe/Brussels:20131114T120000 -SUMMARY: Génie logiciel et gestion de projets -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Van Der Straeten, Ragnhild \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131121T100000 -DTEND;TZID=Europe/Brussels:20131121T120000 -SUMMARY: Génie logiciel et gestion de projets -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Van Der Straeten, Ragnhild \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131128T100000 -DTEND;TZID=Europe/Brussels:20131128T120000 -SUMMARY: Génie logiciel et gestion de projets -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Van Der Straeten, Ragnhild \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131205T100000 -DTEND;TZID=Europe/Brussels:20131205T120000 -SUMMARY: Génie logiciel et gestion de projets -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Van Der Straeten, Ragnhild \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131212T100000 -DTEND;TZID=Europe/Brussels:20131212T120000 -SUMMARY: Génie logiciel et gestion de projets -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Van Der Straeten, Ragnhild \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131107T130000 -DTEND;TZID=Europe/Brussels:20131107T153000 -SUMMARY: Biologie moléculaire et cellulaire -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Gueydan, Cyril \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131114T130000 -DTEND;TZID=Europe/Brussels:20131114T153000 -SUMMARY: Biologie moléculaire et cellulaire -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Gueydan, Cyril \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131121T130000 -DTEND;TZID=Europe/Brussels:20131121T153000 -SUMMARY: Biologie moléculaire et cellulaire -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Gueydan, Cyril \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131128T130000 -DTEND;TZID=Europe/Brussels:20131128T153000 -SUMMARY: Biologie moléculaire et cellulaire -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Gueydan, Cyril \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131205T130000 -DTEND;TZID=Europe/Brussels:20131205T153000 -SUMMARY: Biologie moléculaire et cellulaire -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Gueydan, Cyril \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131219T130000 -DTEND;TZID=Europe/Brussels:20131219T153000 -SUMMARY: Biologie moléculaire et cellulaire -LOCATION: S.UD2.218A -DESCRIPTION: Professeur: Gueydan, Cyril \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131107T160000 -DTEND;TZID=Europe/Brussels:20131107T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131114T160000 -DTEND;TZID=Europe/Brussels:20131114T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131121T160000 -DTEND;TZID=Europe/Brussels:20131121T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131128T160000 -DTEND;TZID=Europe/Brussels:20131128T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131205T160000 -DTEND;TZID=Europe/Brussels:20131205T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131219T160000 -DTEND;TZID=Europe/Brussels:20131219T180000 -SUMMARY: Biochimie métabolique et structurale -LOCATION: P.FORUM.D -DESCRIPTION: Professeur: Vandenbranden, Michel, Kruys, Véronique \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130920T140000 -DTEND;TZID=Europe/Brussels:20130920T170000 -SUMMARY: Réseaux -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Leduc, Guy \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131004T140000 -DTEND;TZID=Europe/Brussels:20131004T170000 -SUMMARY: Réseaux -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: Leduc, Guy \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130920T080000 -DTEND;TZID=Europe/Brussels:20130920T100000 -SUMMARY: Mathématiques discrètes -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131004T080000 -DTEND;TZID=Europe/Brussels:20131004T100000 -SUMMARY: Mathématiques discrètes -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131011T080000 -DTEND;TZID=Europe/Brussels:20131011T100000 -SUMMARY: Mathématiques discrètes -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131018T080000 -DTEND;TZID=Europe/Brussels:20131018T100000 -SUMMARY: Mathématiques discrètes -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131025T080000 -DTEND;TZID=Europe/Brussels:20131025T100000 -SUMMARY: Mathématiques discrètes -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131108T080000 -DTEND;TZID=Europe/Brussels:20131108T100000 -SUMMARY: Mathématiques discrètes -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131115T080000 -DTEND;TZID=Europe/Brussels:20131115T100000 -SUMMARY: Mathématiques discrètes -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131122T080000 -DTEND;TZID=Europe/Brussels:20131122T100000 -SUMMARY: Mathématiques discrètes -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131129T080000 -DTEND;TZID=Europe/Brussels:20131129T100000 -SUMMARY: Mathématiques discrètes -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131206T080000 -DTEND;TZID=Europe/Brussels:20131206T100000 -SUMMARY: Mathématiques discrètes -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131213T080000 -DTEND;TZID=Europe/Brussels:20131213T100000 -SUMMARY: Mathématiques discrètes -LOCATION: P.2NO.506 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131011T140000 -DTEND;TZID=Europe/Brussels:20131011T170000 -SUMMARY: Réseaux -LOCATION: P.OF.2070 -DESCRIPTION: Professeur: Leduc, Guy \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131018T140000 -DTEND;TZID=Europe/Brussels:20131018T170000 -SUMMARY: Réseaux -LOCATION: P.OF.2070 -DESCRIPTION: Professeur: Leduc, Guy \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131108T140000 -DTEND;TZID=Europe/Brussels:20131108T170000 -SUMMARY: Réseaux -LOCATION: P.OF.2070 -DESCRIPTION: Professeur: Leduc, Guy \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131115T140000 -DTEND;TZID=Europe/Brussels:20131115T170000 -SUMMARY: Réseaux -LOCATION: P.OF.2070 -DESCRIPTION: Professeur: Leduc, Guy \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131122T140000 -DTEND;TZID=Europe/Brussels:20131122T170000 -SUMMARY: Réseaux -LOCATION: P.OF.2070 -DESCRIPTION: Professeur: Leduc, Guy \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131129T140000 -DTEND;TZID=Europe/Brussels:20131129T170000 -SUMMARY: Réseaux -LOCATION: P.OF.2070 -DESCRIPTION: Professeur: Leduc, Guy \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131206T140000 -DTEND;TZID=Europe/Brussels:20131206T170000 -SUMMARY: Réseaux -LOCATION: P.OF.2070 -DESCRIPTION: Professeur: Leduc, Guy \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131213T140000 -DTEND;TZID=Europe/Brussels:20131213T170000 -SUMMARY: Réseaux -LOCATION: P.OF.2070 -DESCRIPTION: Professeur: Leduc, Guy \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131220T140000 -DTEND;TZID=Europe/Brussels:20131220T170000 -SUMMARY: Réseaux -LOCATION: P.OF.2070 -DESCRIPTION: Professeur: Leduc, Guy \n Assistant -END:VEVENT - - -END:VCALENDAR diff --git a/tests/gehol/MA1.ics b/tests/gehol/MA1.ics deleted file mode 100644 index 041e0c8b..00000000 --- a/tests/gehol/MA1.ics +++ /dev/null @@ -1,1510 +0,0 @@ -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//hacksw/handcal//NONSGML v1.0//EN - -BEGIN:VTIMEZONE -TZID:Europe/Brussels -X-LIC-LOCATION:Europe/Brussels -BEGIN:STANDARD -DTSTART:20111030T020000 -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -TZNAME:CET -END:STANDARD -BEGIN:DAYLIGHT -DTSTART:20120325T030000 -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -TZNAME:CEST -END:DAYLIGHT -END:VTIMEZONE - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130923T080000 -DTEND;TZID=Europe/Brussels:20130923T100000 -SUMMARY: Data structures and algorithms -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131007T080000 -DTEND;TZID=Europe/Brussels:20131007T100000 -SUMMARY: Data structures and algorithms -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131021T080000 -DTEND;TZID=Europe/Brussels:20131021T100000 -SUMMARY: Data structures and algorithms -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131104T080000 -DTEND;TZID=Europe/Brussels:20131104T100000 -SUMMARY: Data structures and algorithms -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131125T080000 -DTEND;TZID=Europe/Brussels:20131125T100000 -SUMMARY: Data structures and algorithms -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131209T080000 -DTEND;TZID=Europe/Brussels:20131209T100000 -SUMMARY: Data structures and algorithms -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130923T100000 -DTEND;TZID=Europe/Brussels:20130923T130000 -SUMMARY: Computability and complexity -LOCATION: P.FORUM.H -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131007T100000 -DTEND;TZID=Europe/Brussels:20131007T130000 -SUMMARY: Computability and complexity -LOCATION: P.FORUM.H -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131014T100000 -DTEND;TZID=Europe/Brussels:20131014T130000 -SUMMARY: Computability and complexity -LOCATION: P.FORUM.H -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131021T100000 -DTEND;TZID=Europe/Brussels:20131021T130000 -SUMMARY: Computability and complexity -LOCATION: P.FORUM.H -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131028T100000 -DTEND;TZID=Europe/Brussels:20131028T130000 -SUMMARY: Computability and complexity -LOCATION: P.FORUM.H -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131104T100000 -DTEND;TZID=Europe/Brussels:20131104T130000 -SUMMARY: Computability and complexity -LOCATION: P.FORUM.H -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131118T100000 -DTEND;TZID=Europe/Brussels:20131118T130000 -SUMMARY: Computability and complexity -LOCATION: P.FORUM.H -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131125T100000 -DTEND;TZID=Europe/Brussels:20131125T130000 -SUMMARY: Computability and complexity -LOCATION: P.FORUM.H -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131202T100000 -DTEND;TZID=Europe/Brussels:20131202T130000 -SUMMARY: Computability and complexity -LOCATION: P.FORUM.H -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131209T100000 -DTEND;TZID=Europe/Brussels:20131209T130000 -SUMMARY: Computability and complexity -LOCATION: P.FORUM.H -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131216T100000 -DTEND;TZID=Europe/Brussels:20131216T130000 -SUMMARY: Computability and complexity -LOCATION: P.FORUM.H -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131104T140000 -DTEND;TZID=Europe/Brussels:20131104T160000 -SUMMARY: Computational geometry -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: Langerman, Stefan \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131118T140000 -DTEND;TZID=Europe/Brussels:20131118T160000 -SUMMARY: Computational geometry -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: Langerman, Stefan \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131125T140000 -DTEND;TZID=Europe/Brussels:20131125T160000 -SUMMARY: Computational geometry -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: Langerman, Stefan \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131202T140000 -DTEND;TZID=Europe/Brussels:20131202T160000 -SUMMARY: Computational geometry -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: Langerman, Stefan \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131209T140000 -DTEND;TZID=Europe/Brussels:20131209T160000 -SUMMARY: Computational geometry -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: Langerman, Stefan \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131216T140000 -DTEND;TZID=Europe/Brussels:20131216T160000 -SUMMARY: Computational geometry -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: Langerman, Stefan \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130917T160000 -DTEND;TZID=Europe/Brussels:20130917T180000 -SUMMARY: Data structures and algorithms -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: Cardinal, Jean \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130924T160000 -DTEND;TZID=Europe/Brussels:20130924T180000 -SUMMARY: Data structures and algorithms -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: Cardinal, Jean \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131001T160000 -DTEND;TZID=Europe/Brussels:20131001T180000 -SUMMARY: Data structures and algorithms -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: Cardinal, Jean \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131008T160000 -DTEND;TZID=Europe/Brussels:20131008T180000 -SUMMARY: Data structures and algorithms -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: Cardinal, Jean \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131015T160000 -DTEND;TZID=Europe/Brussels:20131015T180000 -SUMMARY: Data structures and algorithms -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: Cardinal, Jean \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131022T160000 -DTEND;TZID=Europe/Brussels:20131022T180000 -SUMMARY: Data structures and algorithms -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: Cardinal, Jean \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131105T160000 -DTEND;TZID=Europe/Brussels:20131105T180000 -SUMMARY: Data structures and algorithms -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: Cardinal, Jean \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131112T160000 -DTEND;TZID=Europe/Brussels:20131112T180000 -SUMMARY: Data structures and algorithms -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: Cardinal, Jean \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131119T160000 -DTEND;TZID=Europe/Brussels:20131119T180000 -SUMMARY: Data structures and algorithms -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: Cardinal, Jean \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131126T160000 -DTEND;TZID=Europe/Brussels:20131126T180000 -SUMMARY: Data structures and algorithms -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: Cardinal, Jean \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131203T160000 -DTEND;TZID=Europe/Brussels:20131203T180000 -SUMMARY: Data structures and algorithms -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: Cardinal, Jean \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131210T160000 -DTEND;TZID=Europe/Brussels:20131210T180000 -SUMMARY: Data structures and algorithms -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: Cardinal, Jean \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130924T160000 -DTEND;TZID=Europe/Brussels:20130924T180000 -SUMMARY: Structure and interpretation of computer programs -LOCATION: -DESCRIPTION: Professeur: De Meuter, Wolfgang \n Assistant VUB10F720 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131001T160000 -DTEND;TZID=Europe/Brussels:20131001T180000 -SUMMARY: Structure and interpretation of computer programs -LOCATION: -DESCRIPTION: Professeur: De Meuter, Wolfgang \n Assistant VUB10F720 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131008T160000 -DTEND;TZID=Europe/Brussels:20131008T180000 -SUMMARY: Structure and interpretation of computer programs -LOCATION: -DESCRIPTION: Professeur: De Meuter, Wolfgang \n Assistant VUB10F720 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131015T160000 -DTEND;TZID=Europe/Brussels:20131015T180000 -SUMMARY: Structure and interpretation of computer programs -LOCATION: -DESCRIPTION: Professeur: De Meuter, Wolfgang \n Assistant VUB10F720 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131022T160000 -DTEND;TZID=Europe/Brussels:20131022T180000 -SUMMARY: Structure and interpretation of computer programs -LOCATION: -DESCRIPTION: Professeur: De Meuter, Wolfgang \n Assistant VUB10F720 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131105T160000 -DTEND;TZID=Europe/Brussels:20131105T180000 -SUMMARY: Structure and interpretation of computer programs -LOCATION: -DESCRIPTION: Professeur: De Meuter, Wolfgang \n Assistant VUB10F720 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131112T160000 -DTEND;TZID=Europe/Brussels:20131112T180000 -SUMMARY: Structure and interpretation of computer programs -LOCATION: -DESCRIPTION: Professeur: De Meuter, Wolfgang \n Assistant VUB10F720 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131119T160000 -DTEND;TZID=Europe/Brussels:20131119T180000 -SUMMARY: Structure and interpretation of computer programs -LOCATION: -DESCRIPTION: Professeur: De Meuter, Wolfgang \n Assistant VUB10F720 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131126T160000 -DTEND;TZID=Europe/Brussels:20131126T180000 -SUMMARY: Structure and interpretation of computer programs -LOCATION: -DESCRIPTION: Professeur: De Meuter, Wolfgang \n Assistant VUB10F720 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131203T160000 -DTEND;TZID=Europe/Brussels:20131203T180000 -SUMMARY: Structure and interpretation of computer programs -LOCATION: -DESCRIPTION: Professeur: De Meuter, Wolfgang \n Assistant VUB10F720 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131210T160000 -DTEND;TZID=Europe/Brussels:20131210T180000 -SUMMARY: Structure and interpretation of computer programs -LOCATION: -DESCRIPTION: Professeur: De Meuter, Wolfgang \n Assistant VUB10F720 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131217T160000 -DTEND;TZID=Europe/Brussels:20131217T180000 -SUMMARY: Structure and interpretation of computer programs -LOCATION: -DESCRIPTION: Professeur: De Meuter, Wolfgang \n Assistant VUB10F720 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131105T160000 -DTEND;TZID=Europe/Brussels:20131105T180000 -SUMMARY: Decision Engineering -LOCATION: S.P4.1.10 (P4.1.303) -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131119T160000 -DTEND;TZID=Europe/Brussels:20131119T180000 -SUMMARY: Decision Engineering -LOCATION: S.P4.1.10 (P4.1.303) -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131126T160000 -DTEND;TZID=Europe/Brussels:20131126T180000 -SUMMARY: Decision Engineering -LOCATION: S.P4.1.10 (P4.1.303) -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131203T160000 -DTEND;TZID=Europe/Brussels:20131203T180000 -SUMMARY: Decision Engineering -LOCATION: S.P4.1.10 (P4.1.303) -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131210T160000 -DTEND;TZID=Europe/Brussels:20131210T180000 -SUMMARY: Decision Engineering -LOCATION: S.P4.1.10 (P4.1.303) -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131217T160000 -DTEND;TZID=Europe/Brussels:20131217T180000 -SUMMARY: Decision Engineering -LOCATION: S.P4.1.10 (P4.1.303) -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131218T150000 -DTEND;TZID=Europe/Brussels:20131218T170000 -SUMMARY: Decision Engineering -LOCATION: S.UB4.132 -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131106T150000 -DTEND;TZID=Europe/Brussels:20131106T170000 -SUMMARY: Decision Engineering -LOCATION: S.C4.219 -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131113T150000 -DTEND;TZID=Europe/Brussels:20131113T170000 -SUMMARY: Decision Engineering -LOCATION: S.C4.219 -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131211T140000 -DTEND;TZID=Europe/Brussels:20131211T160000 -SUMMARY: Introduction to language theory and compilation -LOCATION: P.OF.2066 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131023T160000 -DTEND;TZID=Europe/Brussels:20131023T180000 -SUMMARY: Structure and interpretation of computer programs -LOCATION: -DESCRIPTION: Professeur: De Meuter, Wolfgang \n Assistant 2NO4.009 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131030T160000 -DTEND;TZID=Europe/Brussels:20131030T180000 -SUMMARY: Structure and interpretation of computer programs -LOCATION: -DESCRIPTION: Professeur: De Meuter, Wolfgang \n Assistant 2NO4.009 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131106T160000 -DTEND;TZID=Europe/Brussels:20131106T180000 -SUMMARY: Structure and interpretation of computer programs -LOCATION: -DESCRIPTION: Professeur: De Meuter, Wolfgang \n Assistant 2NO4.009 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131113T160000 -DTEND;TZID=Europe/Brussels:20131113T180000 -SUMMARY: Structure and interpretation of computer programs -LOCATION: -DESCRIPTION: Professeur: De Meuter, Wolfgang \n Assistant 2NO4.009 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131120T160000 -DTEND;TZID=Europe/Brussels:20131120T180000 -SUMMARY: Structure and interpretation of computer programs -LOCATION: -DESCRIPTION: Professeur: De Meuter, Wolfgang \n Assistant 2NO4.009 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131127T160000 -DTEND;TZID=Europe/Brussels:20131127T180000 -SUMMARY: Structure and interpretation of computer programs -LOCATION: -DESCRIPTION: Professeur: De Meuter, Wolfgang \n Assistant 2NO4.009 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131204T160000 -DTEND;TZID=Europe/Brussels:20131204T180000 -SUMMARY: Structure and interpretation of computer programs -LOCATION: -DESCRIPTION: Professeur: De Meuter, Wolfgang \n Assistant 2NO4.009 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131211T160000 -DTEND;TZID=Europe/Brussels:20131211T180000 -SUMMARY: Structure and interpretation of computer programs -LOCATION: -DESCRIPTION: Professeur: De Meuter, Wolfgang \n Assistant 2NO4.009 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131218T160000 -DTEND;TZID=Europe/Brussels:20131218T180000 -SUMMARY: Structure and interpretation of computer programs -LOCATION: -DESCRIPTION: Professeur: De Meuter, Wolfgang \n Assistant 2NO4.009 -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131002T080000 -DTEND;TZID=Europe/Brussels:20131002T100000 -SUMMARY: Operating systems II -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: Goossens, Joël \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131009T080000 -DTEND;TZID=Europe/Brussels:20131009T100000 -SUMMARY: Operating systems II -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: Goossens, Joël \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131016T080000 -DTEND;TZID=Europe/Brussels:20131016T100000 -SUMMARY: Operating systems II -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: Goossens, Joël \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131023T080000 -DTEND;TZID=Europe/Brussels:20131023T100000 -SUMMARY: Operating systems II -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: Goossens, Joël \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131030T080000 -DTEND;TZID=Europe/Brussels:20131030T100000 -SUMMARY: Operating systems II -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: Goossens, Joël \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131106T080000 -DTEND;TZID=Europe/Brussels:20131106T100000 -SUMMARY: Operating systems II -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: Goossens, Joël \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131113T080000 -DTEND;TZID=Europe/Brussels:20131113T100000 -SUMMARY: Operating systems II -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: Goossens, Joël \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131127T080000 -DTEND;TZID=Europe/Brussels:20131127T100000 -SUMMARY: Operating systems II -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: Goossens, Joël \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131204T080000 -DTEND;TZID=Europe/Brussels:20131204T100000 -SUMMARY: Operating systems II -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: Goossens, Joël \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131211T080000 -DTEND;TZID=Europe/Brussels:20131211T100000 -SUMMARY: Operating systems II -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: Goossens, Joël \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131218T080000 -DTEND;TZID=Europe/Brussels:20131218T100000 -SUMMARY: Operating systems II -LOCATION: P.OF.2072 -DESCRIPTION: Professeur: Goossens, Joël \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131127T150000 -DTEND;TZID=Europe/Brussels:20131127T170000 -SUMMARY: Decision Engineering -LOCATION: P.FORUM.G -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131204T150000 -DTEND;TZID=Europe/Brussels:20131204T170000 -SUMMARY: Decision Engineering -LOCATION: P.FORUM.G -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131211T150000 -DTEND;TZID=Europe/Brussels:20131211T170000 -SUMMARY: Decision Engineering -LOCATION: P.FORUM.G -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131030T150000 -DTEND;TZID=Europe/Brussels:20131030T170000 -SUMMARY: Decision Engineering -LOCATION: S.UB5.230 -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130918T100000 -DTEND;TZID=Europe/Brussels:20130918T120000 -SUMMARY: Introduction to language theory and compilation -LOCATION: P.FORUM.G -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130925T100000 -DTEND;TZID=Europe/Brussels:20130925T120000 -SUMMARY: Introduction to language theory and compilation -LOCATION: P.FORUM.G -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131002T100000 -DTEND;TZID=Europe/Brussels:20131002T120000 -SUMMARY: Introduction to language theory and compilation -LOCATION: P.FORUM.G -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131009T100000 -DTEND;TZID=Europe/Brussels:20131009T120000 -SUMMARY: Introduction to language theory and compilation -LOCATION: P.FORUM.G -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131016T100000 -DTEND;TZID=Europe/Brussels:20131016T120000 -SUMMARY: Introduction to language theory and compilation -LOCATION: P.FORUM.G -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131023T100000 -DTEND;TZID=Europe/Brussels:20131023T120000 -SUMMARY: Introduction to language theory and compilation -LOCATION: P.FORUM.G -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131030T100000 -DTEND;TZID=Europe/Brussels:20131030T120000 -SUMMARY: Introduction to language theory and compilation -LOCATION: P.FORUM.G -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131106T100000 -DTEND;TZID=Europe/Brussels:20131106T120000 -SUMMARY: Introduction to language theory and compilation -LOCATION: P.FORUM.G -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131113T100000 -DTEND;TZID=Europe/Brussels:20131113T120000 -SUMMARY: Introduction to language theory and compilation -LOCATION: P.FORUM.G -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131127T100000 -DTEND;TZID=Europe/Brussels:20131127T120000 -SUMMARY: Introduction to language theory and compilation -LOCATION: P.FORUM.G -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131204T100000 -DTEND;TZID=Europe/Brussels:20131204T120000 -SUMMARY: Introduction to language theory and compilation -LOCATION: P.FORUM.G -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131211T100000 -DTEND;TZID=Europe/Brussels:20131211T120000 -SUMMARY: Introduction to language theory and compilation -LOCATION: P.FORUM.G -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131218T100000 -DTEND;TZID=Europe/Brussels:20131218T120000 -SUMMARY: Introduction to language theory and compilation -LOCATION: P.FORUM.G -DESCRIPTION: Professeur: Geeraerts, Gilles \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130918T140000 -DTEND;TZID=Europe/Brussels:20130918T160000 -SUMMARY: Computer security -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: Markowitch, Olivier \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130925T140000 -DTEND;TZID=Europe/Brussels:20130925T160000 -SUMMARY: Computer security -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: Markowitch, Olivier \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131009T140000 -DTEND;TZID=Europe/Brussels:20131009T160000 -SUMMARY: Computer security -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: Markowitch, Olivier \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131016T140000 -DTEND;TZID=Europe/Brussels:20131016T160000 -SUMMARY: Computer security -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: Markowitch, Olivier \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131023T140000 -DTEND;TZID=Europe/Brussels:20131023T160000 -SUMMARY: Computer security -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: Markowitch, Olivier \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131030T140000 -DTEND;TZID=Europe/Brussels:20131030T160000 -SUMMARY: Computer security -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: Markowitch, Olivier \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131106T140000 -DTEND;TZID=Europe/Brussels:20131106T160000 -SUMMARY: Computer security -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: Markowitch, Olivier \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131113T140000 -DTEND;TZID=Europe/Brussels:20131113T160000 -SUMMARY: Computer security -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: Markowitch, Olivier \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131127T140000 -DTEND;TZID=Europe/Brussels:20131127T160000 -SUMMARY: Computer security -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: Markowitch, Olivier \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131204T140000 -DTEND;TZID=Europe/Brussels:20131204T160000 -SUMMARY: Computer security -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: Markowitch, Olivier \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131211T140000 -DTEND;TZID=Europe/Brussels:20131211T160000 -SUMMARY: Computer security -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: Markowitch, Olivier \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131218T140000 -DTEND;TZID=Europe/Brussels:20131218T160000 -SUMMARY: Computer security -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: Markowitch, Olivier \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130918T160000 -DTEND;TZID=Europe/Brussels:20130918T180000 -SUMMARY: Introduction to language theory and compilation -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130925T160000 -DTEND;TZID=Europe/Brussels:20130925T180000 -SUMMARY: Introduction to language theory and compilation -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131002T160000 -DTEND;TZID=Europe/Brussels:20131002T180000 -SUMMARY: Introduction to language theory and compilation -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131009T160000 -DTEND;TZID=Europe/Brussels:20131009T180000 -SUMMARY: Introduction to language theory and compilation -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131016T160000 -DTEND;TZID=Europe/Brussels:20131016T180000 -SUMMARY: Introduction to language theory and compilation -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131023T160000 -DTEND;TZID=Europe/Brussels:20131023T180000 -SUMMARY: Introduction to language theory and compilation -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131030T160000 -DTEND;TZID=Europe/Brussels:20131030T180000 -SUMMARY: Introduction to language theory and compilation -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131106T160000 -DTEND;TZID=Europe/Brussels:20131106T180000 -SUMMARY: Introduction to language theory and compilation -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131113T160000 -DTEND;TZID=Europe/Brussels:20131113T180000 -SUMMARY: Introduction to language theory and compilation -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131127T160000 -DTEND;TZID=Europe/Brussels:20131127T180000 -SUMMARY: Introduction to language theory and compilation -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131204T160000 -DTEND;TZID=Europe/Brussels:20131204T180000 -SUMMARY: Introduction to language theory and compilation -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131211T160000 -DTEND;TZID=Europe/Brussels:20131211T180000 -SUMMARY: Introduction to language theory and compilation -LOCATION: P.FORUM.B -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131121T110000 -DTEND;TZID=Europe/Brussels:20131121T130000 -SUMMARY: Decision Engineering -LOCATION: S.H1308 -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131128T110000 -DTEND;TZID=Europe/Brussels:20131128T130000 -SUMMARY: Decision Engineering -LOCATION: S.H1308 -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131212T110000 -DTEND;TZID=Europe/Brussels:20131212T130000 -SUMMARY: Decision Engineering -LOCATION: S.H1308 -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130926T150000 -DTEND;TZID=Europe/Brussels:20130926T170000 -SUMMARY: Operating systems II -LOCATION: P.FORUM.G -DESCRIPTION: Professeur: Goossens, Joël \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131114T110000 -DTEND;TZID=Europe/Brussels:20131114T130000 -SUMMARY: Decision Engineering -LOCATION: S.Salle.Baugniet (S.S.01.326) -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130919T150000 -DTEND;TZID=Europe/Brussels:20130919T170000 -SUMMARY: Operating systems II -LOCATION: P.FORUM.C -DESCRIPTION: Professeur: Goossens, Joël \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131107T080000 -DTEND;TZID=Europe/Brussels:20131107T100000 -SUMMARY: Decision Engineering -LOCATION: S.UB4.136 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131114T080000 -DTEND;TZID=Europe/Brussels:20131114T100000 -SUMMARY: Decision Engineering -LOCATION: S.UB4.136 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131121T080000 -DTEND;TZID=Europe/Brussels:20131121T100000 -SUMMARY: Decision Engineering -LOCATION: S.UB4.136 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131128T080000 -DTEND;TZID=Europe/Brussels:20131128T100000 -SUMMARY: Decision Engineering -LOCATION: S.UB4.136 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131205T080000 -DTEND;TZID=Europe/Brussels:20131205T100000 -SUMMARY: Decision Engineering -LOCATION: S.UB4.136 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130926T130000 -DTEND;TZID=Europe/Brussels:20130926T170000 -SUMMARY: Learning dynamics -LOCATION: -DESCRIPTION: Professeur: Lenaerts, Tom, Nowe, Ann \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131003T130000 -DTEND;TZID=Europe/Brussels:20131003T170000 -SUMMARY: Learning dynamics -LOCATION: -DESCRIPTION: Professeur: Lenaerts, Tom, Nowe, Ann \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131010T130000 -DTEND;TZID=Europe/Brussels:20131010T170000 -SUMMARY: Learning dynamics -LOCATION: -DESCRIPTION: Professeur: Lenaerts, Tom, Nowe, Ann \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131017T130000 -DTEND;TZID=Europe/Brussels:20131017T170000 -SUMMARY: Learning dynamics -LOCATION: -DESCRIPTION: Professeur: Lenaerts, Tom, Nowe, Ann \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131024T130000 -DTEND;TZID=Europe/Brussels:20131024T170000 -SUMMARY: Learning dynamics -LOCATION: -DESCRIPTION: Professeur: Lenaerts, Tom, Nowe, Ann \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131031T130000 -DTEND;TZID=Europe/Brussels:20131031T170000 -SUMMARY: Learning dynamics -LOCATION: -DESCRIPTION: Professeur: Lenaerts, Tom, Nowe, Ann \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131107T130000 -DTEND;TZID=Europe/Brussels:20131107T170000 -SUMMARY: Learning dynamics -LOCATION: -DESCRIPTION: Professeur: Lenaerts, Tom, Nowe, Ann \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131114T130000 -DTEND;TZID=Europe/Brussels:20131114T170000 -SUMMARY: Learning dynamics -LOCATION: -DESCRIPTION: Professeur: Lenaerts, Tom, Nowe, Ann \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131121T130000 -DTEND;TZID=Europe/Brussels:20131121T170000 -SUMMARY: Learning dynamics -LOCATION: -DESCRIPTION: Professeur: Lenaerts, Tom, Nowe, Ann \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131128T130000 -DTEND;TZID=Europe/Brussels:20131128T170000 -SUMMARY: Learning dynamics -LOCATION: -DESCRIPTION: Professeur: Lenaerts, Tom, Nowe, Ann \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131205T130000 -DTEND;TZID=Europe/Brussels:20131205T170000 -SUMMARY: Learning dynamics -LOCATION: -DESCRIPTION: Professeur: Lenaerts, Tom, Nowe, Ann \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131212T130000 -DTEND;TZID=Europe/Brussels:20131212T170000 -SUMMARY: Learning dynamics -LOCATION: -DESCRIPTION: Professeur: Lenaerts, Tom, Nowe, Ann \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131107T120000 -DTEND;TZID=Europe/Brussels:20131107T140000 -SUMMARY: Decision Engineering -LOCATION: P.FORUM.G -DESCRIPTION: Professeur: De Smet, Yves \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131107T140000 -DTEND;TZID=Europe/Brussels:20131107T160000 -SUMMARY: Computational geometry -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131114T140000 -DTEND;TZID=Europe/Brussels:20131114T160000 -SUMMARY: Computational geometry -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131121T140000 -DTEND;TZID=Europe/Brussels:20131121T160000 -SUMMARY: Computational geometry -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131128T140000 -DTEND;TZID=Europe/Brussels:20131128T160000 -SUMMARY: Computational geometry -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131205T140000 -DTEND;TZID=Europe/Brussels:20131205T160000 -SUMMARY: Computational geometry -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131212T140000 -DTEND;TZID=Europe/Brussels:20131212T160000 -SUMMARY: Computational geometry -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131219T140000 -DTEND;TZID=Europe/Brussels:20131219T160000 -SUMMARY: Computational geometry -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131004T080000 -DTEND;TZID=Europe/Brussels:20131004T100000 -SUMMARY: Computer security -LOCATION: P.NO3.07A -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131004T100000 -DTEND;TZID=Europe/Brussels:20131004T120000 -SUMMARY: Operating systems II -LOCATION: P.OF.2070 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131018T100000 -DTEND;TZID=Europe/Brussels:20131018T120000 -SUMMARY: Operating systems II -LOCATION: P.OF.2070 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20130920T080000 -DTEND;TZID=Europe/Brussels:20130920T100000 -SUMMARY: Computer security -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131011T080000 -DTEND;TZID=Europe/Brussels:20131011T100000 -SUMMARY: Computer security -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131018T080000 -DTEND;TZID=Europe/Brussels:20131018T100000 -SUMMARY: Computer security -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131025T080000 -DTEND;TZID=Europe/Brussels:20131025T100000 -SUMMARY: Computer security -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131108T080000 -DTEND;TZID=Europe/Brussels:20131108T100000 -SUMMARY: Computer security -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131115T080000 -DTEND;TZID=Europe/Brussels:20131115T100000 -SUMMARY: Computer security -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131122T080000 -DTEND;TZID=Europe/Brussels:20131122T100000 -SUMMARY: Computer security -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131206T080000 -DTEND;TZID=Europe/Brussels:20131206T100000 -SUMMARY: Computer security -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131213T080000 -DTEND;TZID=Europe/Brussels:20131213T100000 -SUMMARY: Computer security -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131220T080000 -DTEND;TZID=Europe/Brussels:20131220T100000 -SUMMARY: Computer security -LOCATION: P.FORUM.F -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131115T100000 -DTEND;TZID=Europe/Brussels:20131115T120000 -SUMMARY: Operating systems II -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131122T100000 -DTEND;TZID=Europe/Brussels:20131122T120000 -SUMMARY: Operating systems II -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131206T100000 -DTEND;TZID=Europe/Brussels:20131206T120000 -SUMMARY: Operating systems II -LOCATION: P.FORUM.A -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -END:VCALENDAR diff --git a/tests/gehol/MA2.ics b/tests/gehol/MA2.ics deleted file mode 100644 index 535dae9a..00000000 --- a/tests/gehol/MA2.ics +++ /dev/null @@ -1,196 +0,0 @@ -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//hacksw/handcal//NONSGML v1.0//EN - -BEGIN:VTIMEZONE -TZID:Europe/Brussels -X-LIC-LOCATION:Europe/Brussels -BEGIN:STANDARD -DTSTART:20111030T020000 -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -TZNAME:CET -END:STANDARD -BEGIN:DAYLIGHT -DTSTART:20120325T030000 -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -TZNAME:CEST -END:DAYLIGHT -END:VTIMEZONE - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131118T100000 -DTEND;TZID=Europe/Brussels:20131118T120000 -SUMMARY: Information technology in society -LOCATION: -DESCRIPTION: Professeur: Wilkin, Luc \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131104T100000 -DTEND;TZID=Europe/Brussels:20131104T120000 -SUMMARY: Information technology in society -LOCATION: S.H3228 -DESCRIPTION: Professeur: Wilkin, Luc \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131202T100000 -DTEND;TZID=Europe/Brussels:20131202T120000 -SUMMARY: Information technology in society -LOCATION: S.H3228 -DESCRIPTION: Professeur: Wilkin, Luc \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131125T100000 -DTEND;TZID=Europe/Brussels:20131125T120000 -SUMMARY: Information technology in society -LOCATION: S.R42.5.110 -DESCRIPTION: Professeur: Wilkin, Luc \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131209T100000 -DTEND;TZID=Europe/Brussels:20131209T120000 -SUMMARY: Information technology in society -LOCATION: S.UB4.136 -DESCRIPTION: Professeur: Wilkin, Luc \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131216T100000 -DTEND;TZID=Europe/Brussels:20131216T120000 -SUMMARY: Information technology in society -LOCATION: S.UB4.136 -DESCRIPTION: Professeur: Wilkin, Luc \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131104T140000 -DTEND;TZID=Europe/Brussels:20131104T160000 -SUMMARY: Computational geometry -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: Langerman, Stefan \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131118T140000 -DTEND;TZID=Europe/Brussels:20131118T160000 -SUMMARY: Computational geometry -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: Langerman, Stefan \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131125T140000 -DTEND;TZID=Europe/Brussels:20131125T160000 -SUMMARY: Computational geometry -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: Langerman, Stefan \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131202T140000 -DTEND;TZID=Europe/Brussels:20131202T160000 -SUMMARY: Computational geometry -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: Langerman, Stefan \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131209T140000 -DTEND;TZID=Europe/Brussels:20131209T160000 -SUMMARY: Computational geometry -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: Langerman, Stefan \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131216T140000 -DTEND;TZID=Europe/Brussels:20131216T160000 -SUMMARY: Computational geometry -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: Langerman, Stefan \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131107T140000 -DTEND;TZID=Europe/Brussels:20131107T160000 -SUMMARY: Computational geometry -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131114T140000 -DTEND;TZID=Europe/Brussels:20131114T160000 -SUMMARY: Computational geometry -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131121T140000 -DTEND;TZID=Europe/Brussels:20131121T160000 -SUMMARY: Computational geometry -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131128T140000 -DTEND;TZID=Europe/Brussels:20131128T160000 -SUMMARY: Computational geometry -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131205T140000 -DTEND;TZID=Europe/Brussels:20131205T160000 -SUMMARY: Computational geometry -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131212T140000 -DTEND;TZID=Europe/Brussels:20131212T160000 -SUMMARY: Computational geometry -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -BEGIN:VEVENT -DTSTART;TZID=Europe/Brussels:20131219T140000 -DTEND;TZID=Europe/Brussels:20131219T160000 -SUMMARY: Computational geometry -LOCATION: P.OF.2078 -DESCRIPTION: Professeur: \n Assistant -END:VEVENT - - -END:VCALENDAR diff --git a/tests/misc.py b/tests/misc.py deleted file mode 100644 index aad8f5bb..00000000 --- a/tests/misc.py +++ /dev/null @@ -1,57 +0,0 @@ -import os -import unittest - -from ics.event import Event -from ics.icalendar import Calendar -from .fixture import cal32 - - -class TestEvent(unittest.TestCase): - def test_issue_90(self): - Calendar(cal32) - - def test_fixtures_case_meetup(self): - with open(os.path.join(os.path.dirname(__file__), "fixtures/case_meetup.ics")) as f: - Calendar(f.read()) - - def test_fixtures_encoding(self): - with open(os.path.join(os.path.dirname(__file__), "fixtures/encoding.ics")) as f: - Calendar(f.read()) - - def test_fixtures_groupscheduled(self): - with open(os.path.join(os.path.dirname(__file__), "fixtures/groupscheduled.ics")) as f: - Calendar(f.read()) - - def test_fixtures_multiple(self): - with open(os.path.join(os.path.dirname(__file__), "fixtures/multiple.ics")) as f: - Calendar.parse_multiple(f.read()) - - def test_fixtures_recurrence(self): - with open(os.path.join(os.path.dirname(__file__), "fixtures/recurrence.ics")) as f: - Calendar(f.read()) - - def test_fixtures_small(self): - with open(os.path.join(os.path.dirname(__file__), "fixtures/small.ics")) as f: - Calendar(f.read()) - - def test_fixtures_time(self): - with open(os.path.join(os.path.dirname(__file__), "fixtures/time.ics")) as f: - Calendar(f.read().replace("BEGIN:VCALENDAR", "BEGIN:VCALENDAR\nPRODID:Fixture")) - - def test_fixtures_timezoned(self): - with open(os.path.join(os.path.dirname(__file__), "fixtures/timezoned.ics")) as f: - Calendar(f.read()) - - def test_fixtures_utf_8_emoji(self): - with open(os.path.join(os.path.dirname(__file__), "fixtures/utf-8-emoji.ics")) as f: - Calendar(f.read()) - - def test_fixtures_romeo_juliet(self): - with open(os.path.join(os.path.dirname(__file__), "fixtures/Romeo-and-Juliet.ics")) as f: - event: Event = next(iter(Calendar(f.read()).events)) - with open(os.path.join(os.path.dirname(__file__), "fixtures/Romeo-and-Juliet.txt")) as f: - self.assertEqual(event.description, f.read()) - - def test_fixtures_spaces(self): - with open(os.path.join(os.path.dirname(__file__), "fixtures/spaces.ics")) as f: - Calendar(f.read()) diff --git a/tests/parse.py b/tests/parse.py deleted file mode 100644 index 0abc20be..00000000 --- a/tests/parse.py +++ /dev/null @@ -1,76 +0,0 @@ -import unittest - -from ics.icalendar import Calendar -from ics.grammar.parse import (Container, ContentLine, ParseError, lines_to_container, - string_to_container) - -from .fixture import cal1, cal5, cal11 - - -class TestParse(unittest.TestCase): - - def test_parse(self): - content = string_to_container(cal5) - self.assertEqual(1, len(content)) - - cal = content.pop() - self.assertEqual('VCALENDAR', cal.name) - self.assertTrue(isinstance(cal, Container)) - self.assertEqual('VERSION', cal[0].name) - self.assertEqual('2.0', cal[0].value) - self.assertEqual(cal5.strip().splitlines(), str(cal).strip().splitlines()) - - def test_one_line(self): - ics = 'DTSTART;TZID=Europe/Brussels:20131029T103000' - reader = lines_to_container([ics]) - self.assertEqual(next(iter(reader)), ContentLine( - 'DTSTART', - {'TZID': ['Europe/Brussels']}, - '20131029T103000' - )) - - def test_many_lines(self): - i = 0 - for line in string_to_container(cal1)[0]: - self.assertNotEqual('', line.name) - if isinstance(line, ContentLine): - self.assertNotEqual('', line.value) - if line.name == 'DESCRIPTION': - self.assertEqual('Lorem ipsum dolor sit amet, \ - consectetur adipiscing elit. \ - Sed vitae facilisis enim. \ - Morbi blandit et lectus venenatis tristique. \ - Donec sit amet egestas lacus. \ - Donec ullamcorper, mi vitae congue dictum, \ - quam dolor luctus augue, id cursus purus justo vel lorem. \ - Ut feugiat enim ipsum, quis porta nibh ultricies congue. \ - Pellentesque nisl mi, molestie id sem vel, \ - vehicula nullam.', line.value) - i += 1 - - def test_end_different(self): - - with self.assertRaises(ParseError): - Calendar(cal11) - - -class TestContainer(unittest.TestCase): - - def test_repr(self): - - e = ContentLine(name="VTEST", value="cocu !") - c = Container("test", e) - - self.assertEqual("", repr(c)) - - -class TestLine(unittest.TestCase): - - def test_repr(self): - - c = ContentLine(name="VTEST", value="cocu !") - self.assertEqual("", repr(c)) - - def test_get_item(self): - l = ContentLine(name="VTEST", value="cocu !", params={"plop": "plip"}) - self.assertEqual(l['plop'], "plip") diff --git a/tests/test.py b/tests/test.py deleted file mode 100644 index 050911e2..00000000 --- a/tests/test.py +++ /dev/null @@ -1,21 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import os -import unittest - -from ics.grammar.parse import string_to_container - - -class TestFunctional(unittest.TestCase): - - def test_gehol(self): - # convert ics to utf8: recode l9..utf8 *.ics - cal = os.path.join(os.path.dirname(__file__), "gehol", "BA1.ics") - with open(cal) as ics: - ics = ics.read() - ics = string_to_container(ics)[0] - self.assertTrue(ics) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/timeline.py b/tests/timeline.py deleted file mode 100644 index b7caf8ac..00000000 --- a/tests/timeline.py +++ /dev/null @@ -1,126 +0,0 @@ -import unittest -from datetime import datetime - -from ics.event import Event -from ics.icalendar import Calendar -from ics.timeline import Timeline - - -class TestTimeline(unittest.TestCase): - - def test_type(self): - - c = Calendar() - self.assertIsInstance(c.timeline, Timeline) - - def test_iter_is_ordered(self): - c = Calendar() - c.events.append(Event(begin=datetime.fromtimestamp(1236))) - c.events.append(Event(begin=datetime.fromtimestamp(1235))) - c.events.append(Event(begin=datetime.fromtimestamp(1234))) - - last = None - for event in c.timeline: - if last is not None: - self.assertGreaterEqual(event, last) - last = event - - def test_iter_over_all(self): - c = Calendar() - c.events.append(Event(begin=datetime.fromtimestamp(1234))) - c.events.append(Event(begin=datetime.fromtimestamp(1235))) - c.events.append(Event(begin=datetime.fromtimestamp(1236))) - - i = 0 - for event in c.timeline: - i += 1 - - self.assertEqual(i, 3) - - def test_iter_does_not_show_undefined_events(self): - c = Calendar() - - empty = Event() - c.events.append(empty) - c.events.append(Event(begin=datetime.fromtimestamp(1234))) - c.events.append(Event(begin=datetime.fromtimestamp(1235))) - - for event in c.timeline: - self.assertIsNot(empty, event) - - def test_included(self): - - c = Calendar() - - e = [ - Event(begin=datetime(2015, 10, 10)), - Event(begin=datetime(2010, 10, 10)), - Event(begin=datetime(2020, 10, 10)), - Event(begin=datetime(2015, 1, 10)), - Event(begin=datetime(2014, 1, 10), end=datetime(2018, 1, 10)), - ] - - for ev in e: - c.events.append(ev) - - included = list(c.timeline.included( - (datetime(2013, 10, 10)), - (datetime(2017, 10, 10)) - )) - self.assertSequenceEqual(included, [e[3]] + [e[0]]) - - def test_overlapping(self): - c = Calendar() - - e = [ - Event(begin=datetime(2010, 10, 10), end=datetime(2012, 10, 10)), - Event(begin=datetime(2013, 10, 10), end=datetime(2014, 10, 10)), - Event(begin=datetime(2016, 10, 10), end=datetime(2017, 10, 10)), - ] - - for ev in e: - c.events.append(ev) - - overlap = list(c.timeline.overlapping( - (datetime(2011, 10, 10)), - (datetime(2015, 10, 10)) - )) - self.assertSequenceEqual(overlap, [e[0]] + [e[1]]) - - def test_on(self): - - c = Calendar() - - e = [ - Event(begin=datetime(2015, 10, 10)), - Event(begin=datetime(2010, 10, 10)), - Event(begin=datetime(2020, 10, 10)), - Event(begin=datetime(2015, 1, 10)), - Event(begin=datetime(2014, 1, 10), end=datetime(2018, 1, 10)), - ] - - for ev in e: - c.events.append(ev) - - now = (datetime(2015, 10, 10, 12)) - on = list(c.timeline.on(now)) - self.assertSequenceEqual(on, [e[4], e[0]]) - - def test_on_strict(self): - - c = Calendar() - - e = [ - Event(begin=datetime(2015, 10, 10)), - Event(begin=datetime(2010, 10, 10)), - Event(begin=datetime(2020, 10, 10)), - Event(begin=datetime(2015, 1, 10)), - Event(begin=datetime(2014, 1, 10), end=datetime(2018, 1, 10)), - ] - - for ev in e: - c.events.append(ev) - - now = (datetime(2015, 10, 10, 12)) - on = list(c.timeline.on(now, strict=True)) - self.assertSequenceEqual(on, [e[0]]) diff --git a/tests/timespan.py b/tests/timespan.py deleted file mode 100644 index dc6d6348..00000000 --- a/tests/timespan.py +++ /dev/null @@ -1,121 +0,0 @@ -import itertools -from datetime import datetime as dt, timedelta as td - -import dateutil -import pytest - -from ics.timespan import EventTimespan -from ics.utils import floor_datetime_to_midnight - - -class TestEventTimespan(object): - data = [] - - def assert_end_eq_duration(self, by_dur: EventTimespan, by_end: EventTimespan): - - assert by_dur.get_begin() == by_end.get_begin() - assert by_dur.get_effective_end() == by_end.get_effective_end() - assert by_dur.get_effective_duration() == by_end.get_effective_duration() - assert by_dur.get_precision() == by_end.get_precision() - assert by_dur == by_end.convert_end("duration") - assert by_end == by_dur.convert_end("end") - - def assert_make_all_day_valid(self, ts_secs: EventTimespan, ts_days: EventTimespan): - # check resolution for all_day - assert ts_days.get_effective_duration() % td(days=1) == td(0) - assert floor_datetime_to_midnight(ts_days.get_begin()) == ts_days.get_begin() - assert floor_datetime_to_midnight(ts_days.get_effective_end()) == ts_days.get_effective_end() - - # minimum duration is 0s / 1d - assert ts_secs.get_effective_duration() >= td() - assert ts_days.get_effective_duration() >= td(days=1) - - # test inclusion and boundaries - ts_days_begin_wtz = ts_days.get_begin().replace(tzinfo=ts_secs.get_begin().tzinfo) - ts_days_eff_end_wtz = ts_days.get_effective_end().replace(tzinfo=ts_secs.get_effective_end().tzinfo) - assert ts_days_begin_wtz <= ts_secs.get_begin() - assert ts_days_eff_end_wtz >= ts_secs.get_effective_end() - assert ts_days.get_effective_duration() >= ts_secs.get_effective_duration() - - # test that we didn't decrease anything - begin_earlier = ts_secs.get_begin() - ts_days_begin_wtz - end_later = ts_days_eff_end_wtz - ts_secs.get_effective_end() - duration_bigger = ts_days.get_effective_duration() - ts_secs.get_effective_duration() - assert begin_earlier >= td() - assert end_later >= td() - assert duration_bigger >= td() - - # test that we didn't drift too far - assert begin_earlier < td(hours=24) - instant_to_one_day = (ts_secs.get_begin().hour == 0 and ts_secs.get_effective_duration() == td()) - if instant_to_one_day: - assert end_later == td(hours=24) - else: - assert end_later < td(hours=24) - # NOTICE: duration might grow by 48h, not only 24, as we floor the begin time (which might be 23:59) - # and ceil the end time (which might be 00:01) - assert duration_bigger < td(hours=24 * 2) - - # test that we made no unnecessary modification - if ts_secs.get_begin() == floor_datetime_to_midnight(ts_secs.get_begin()): - assert ts_days.get_begin() == ts_secs.get_begin().replace(tzinfo=None) - if ts_secs.get_effective_end() == floor_datetime_to_midnight(ts_secs.get_effective_end()): - if instant_to_one_day: - # here we need to convert duration=0 to duration=1d - assert ts_days.get_effective_end() == ts_secs.get_effective_end().replace(tzinfo=None) + td(days=1) - else: - assert ts_days.get_effective_end() == ts_secs.get_effective_end().replace(tzinfo=None) - - # the following won't hold for events that don't start at 00:00, compare NOTICE above - if ts_secs.get_begin().hour == 0: - if instant_to_one_day: - # here we need to convert duration=0 to duration=1d - assert duration_bigger == td(hours=24) - else: - # if we start at midnight, only the end time is ceiled, which can only add up to 24h instead of 48h - assert duration_bigger < td(hours=24) - - mod_duration = (ts_secs.get_effective_duration() % td(days=1)) - if ts_secs.get_effective_duration() <= td(days=1): - # here we need to convert duration<1d to duration=1d - assert ts_days.get_effective_duration() == td(days=1) - elif mod_duration == td(): - assert ts_days.get_effective_duration() == ts_secs.get_effective_duration() - else: - assert ts_days.get_effective_duration() == ts_secs.get_effective_duration() + td(days=1) - mod_duration - - # log data - if self.data is not None: - self.data.append(( - str(ts_secs), ts_secs.get_effective_duration().days * 24 + ts_secs.get_effective_duration().seconds / 3600, - str(ts_days), ts_days.get_effective_duration().days * 24 + ts_days.get_effective_duration().seconds / 3600, - begin_earlier, end_later, duration_bigger, - )) - - # this generates quite a lot of different dates, but make_all_day is hard, so better test more corner cases than less - @pytest.mark.parametrize(["begin_tz", "begin_hour", "dur_hours"], [ - (begin_tz, begin_hour, dur_hours) - for begin_tz in range(-3, 3) - for begin_hour in range(-3, 3) - for dur_hours in itertools.chain(range(0, 5), range(24 - 4, 24 + 5), range(48 - 4, 48 + 5)) - ]) - def test(self, begin_tz, begin_hour, dur_hours): - tzoffset = dateutil.tz.tzoffset("%+03d:00" % begin_tz, td(hours=begin_tz)) - start = dt(2019, 5, 29, tzinfo=tzoffset) + td(hours=begin_hour) - timespan_seconds = EventTimespan(begin_time=start, duration=td(hours=dur_hours)) - timespan_all_day = timespan_seconds.make_all_day() - - timespan_seconds_end = EventTimespan(begin_time=start, end_time=start + td(hours=dur_hours)) - timespan_all_day_end = timespan_seconds_end.make_all_day() - - # TODO none of the following will hold if begin_tz and end_tz differ - e.g. for plane flights - self.assert_end_eq_duration(timespan_seconds, timespan_seconds_end) - self.assert_end_eq_duration(timespan_all_day, timespan_all_day_end) - self.assert_make_all_day_valid(timespan_seconds, timespan_all_day) - - # for end_tz in range(-3, 3): - - # from tabulate import tabulate - # if self.data: - # print(tabulate(self.data, headers=("hourly event", "hourly duration", "all-day event", "all-day duration", - # "begins earlier by", "ends later by", "duration bigger by"))) diff --git a/tests/todo.py b/tests/todo.py deleted file mode 100644 index 8becde5b..00000000 --- a/tests/todo.py +++ /dev/null @@ -1,357 +0,0 @@ -import unittest -from datetime import datetime, datetime as dt, timedelta, timezone - -from dateutil.tz import UTC as dateutil_tzutc -from ics.alarm.display import DisplayAlarm -from ics.grammar.parse import Container - -from ics.icalendar import Calendar -from ics.todo import Todo -from .fixture import cal27, cal28, cal29, cal30, cal31 - -datetime_tzutc = timezone.utc - -CRLF = "\r\n" - - -class TestTodo(unittest.TestCase): - maxDiff = None - - def test_init(self): - t = Todo() - self.assertIsNotNone(t.uid) - self.assertIsNotNone(t.dtstamp) - self.assertIsNone(t.completed) - self.assertIsNone(t.created) - self.assertIsNone(t.description) - self.assertIsNone(t.begin) - self.assertIsNone(t.location) - self.assertIsNone(t.percent) - self.assertIsNone(t.priority) - self.assertIsNone(t.summary) - self.assertIsNone(t.url) - self.assertIsNone(t.status) - self.assertEqual(t.extra, Container(name='VTODO')) - - def test_init_non_exclusive_arguments(self): - # attributes percent, priority, begin, due, and duration - # aren't tested here - dtstamp = datetime(2018, 2, 18, 12, 19, tzinfo=datetime_tzutc) - completed = dtstamp + timedelta(days=1) - created = dtstamp + timedelta(seconds=1) - alarms = [DisplayAlarm] - - t = Todo( - uid='uid', - dtstamp=dtstamp, - completed=completed, - created=created, - description='description', - location='location', - name='name', - url='url', - alarms=alarms) - - self.assertEqual(t.uid, 'uid') - self.assertEqual(t.dtstamp, dtstamp) - self.assertEqual(t.completed, completed) - self.assertEqual(t.created, created) - self.assertEqual(t.description, 'description') - self.assertEqual(t.location, 'location') - self.assertEqual(t.summary, 'name') - self.assertEqual(t.url, 'url') - self.assertEqual(t.alarms, alarms) - - def test_percent(self): - t1 = Todo(percent=0) - self.assertEqual(t1.percent, 0) - t2 = Todo(percent=100) - self.assertEqual(t2.percent, 100) - with self.assertRaises(ValueError): - Todo(percent=-1) - with self.assertRaises(ValueError): - Todo(percent=101) - - def test_priority(self): - t1 = Todo(priority=0) - self.assertEqual(t1.priority, 0) - t2 = Todo(priority=9) - self.assertEqual(t2.priority, 9) - with self.assertRaises(ValueError): - Todo(priority=-1) - with self.assertRaises(ValueError): - Todo(priority=10) - - def test_begin(self): - begin = datetime(2018, 2, 18, 12, 19, tzinfo=datetime_tzutc) - t = Todo(begin=begin) - self.assertEqual(t.begin, begin) - - # begin after due - t = Todo(due=datetime.fromtimestamp(1)) - with self.assertRaises(ValueError): - t.begin = datetime.fromtimestamp(2) - - def test_duration(self): - begin = datetime(2018, 2, 18, 12, 19, tzinfo=datetime_tzutc) - t1 = Todo(begin=begin, duration={'hours': 1}) - self.assertEqual(t1.duration, timedelta(hours=1)) - t2 = Todo(begin=begin, duration=(1,)) - self.assertEqual(t2.duration, timedelta(days=1)) - t3 = Todo(begin=begin, duration=timedelta(minutes=1)) - self.assertEqual(t3.duration, timedelta(minutes=1)) - - # Calculate duration from begin and due values - t4 = Todo(begin=begin, due=begin + timedelta(1)) - self.assertEqual(t4.duration, timedelta(1)) - - def test_due(self): - begin = datetime(2018, 2, 18, 12, 19, tzinfo=datetime_tzutc) - due = begin + timedelta(1) - t1 = Todo(due=due) - self.assertEqual(t1.due, begin + timedelta(1)) - - due = begin - timedelta(1) - with self.assertRaises(ValueError): - Todo(begin=begin, due=due) - - # Calculate due from begin and duration value - t2 = Todo(begin=begin, duration=(1,)) - self.assertEqual(t2.due, begin + timedelta(1)) - - def test_invalid_time_attributes(self): - # due and duration must not be set at the same time - with self.assertRaises(ValueError): - Todo(begin=datetime.now(), due=datetime.now() + timedelta(1), duration=timedelta(1)) - - # duration requires begin - with self.assertRaises(ValueError): - Todo(duration=timedelta(1)) - - def test_repr(self): - begin = datetime(2018, 2, 18, 12, 19, tzinfo=datetime_tzutc) - - t1 = Todo() - self.assertEqual(repr(t1), '') - - t2 = Todo(name='foo') - self.assertEqual(repr(t2), "") - - t3 = Todo(name='foo', begin=begin) - self.assertEqual(repr(t3), "") - - t4 = Todo(name='foo', due=begin) - self.assertEqual(repr(t4), "") - - t4 = Todo(name='foo', begin=begin, due=begin + timedelta(1)) - self.assertEqual(repr(t4), - "") - - def test_todo_lt(self): - t1 = Todo() - t2 = Todo(name='a') - t3 = Todo(name='b') - t4 = Todo(due=datetime.fromtimestamp(10)) - t5 = Todo(due=datetime.fromtimestamp(20)) - - # Check comparison by name - self.assertFalse(t1 < t1) - self.assertTrue(t1 < t2) - self.assertFalse(t2 < t1) - self.assertTrue(t2 < t3) - self.assertFalse(t3 < t2) - - # Check comparison by due time - self.assertTrue(t4 < t5) - self.assertFalse(t4 < t4) - self.assertFalse(t5 < t4) - - # Check invalid call - with self.assertRaises(TypeError): - t4 > t4.due - with self.assertRaises(TypeError): - t2 < 1 - - def test_todo_le(self): - t1 = Todo() - t2 = Todo(name='a') - t3 = Todo(name='b') - t4 = Todo(due=datetime.fromtimestamp(10)) - t5 = Todo(due=datetime.fromtimestamp(20)) - - # Check comparison by name - self.assertTrue(t1 <= t1) - self.assertTrue(t1 <= t2) - self.assertFalse(t2 <= t1) - self.assertTrue(t2 <= t3) - self.assertTrue(t2 <= t2) - self.assertFalse(t3 <= t2) - - # Check comparison by due time - self.assertTrue(t4 <= t5) - self.assertTrue(t4 <= t4) - self.assertFalse(t5 <= t4) - - # Check invalid call - with self.assertRaises(TypeError): - t4 > t4.due - with self.assertRaises(TypeError): - t2 <= 1 - - def test_todo_gt(self): - t1 = Todo() - t2 = Todo(name='a') - t3 = Todo(name='b') - t4 = Todo(due=datetime.fromtimestamp(10)) - t5 = Todo(due=datetime.fromtimestamp(20)) - - # Check comparison by name - self.assertFalse(t1 > t1) - self.assertFalse(t1 > t2) - self.assertTrue(t2 > t1) - self.assertFalse(t2 > t3) - self.assertFalse(t2 > t2) - self.assertTrue(t3 > t2) - - # Check comparison by due time - self.assertFalse(t4 > t5) - self.assertFalse(t4 > t4) - self.assertTrue(t5 > t4) - - # Check invalid call - with self.assertRaises(TypeError): - t4 > t4.due - with self.assertRaises(TypeError): - t2 > 1 - - def test_todo_ge(self): - t1 = Todo() - t2 = Todo(name='a') - t3 = Todo(name='b') - t4 = Todo(due=datetime.fromtimestamp(10)) - t5 = Todo(due=datetime.fromtimestamp(20)) - - # Check comparison by name - self.assertTrue(t1 >= t1) - self.assertTrue(t1 <= t2) - self.assertFalse(t2 <= t1) - self.assertFalse(t2 >= t3) - self.assertTrue(t2 >= t2) - self.assertTrue(t3 >= t2) - - # Check comparison by due time - self.assertFalse(t4 >= t5) - self.assertTrue(t4 >= t4) - self.assertTrue(t5 >= t4) - - # Check invalid call - with self.assertRaises(TypeError): - t4 > t4.due - with self.assertRaises(TypeError): - t2 >= 1 - - def test_todo_eq(self): - t1 = Todo() - t2 = Todo() - - self.assertTrue(t1 == t1) - self.assertFalse(t1 == t2) - - def test_todo_ne(self): - t1 = Todo() - t2 = Todo() - - self.assertFalse(t1 != t1) - self.assertTrue(t1 != t2) - - def test_extract(self): - c = Calendar(cal27) - t = next(iter(c.todos)) - self.assertEqual(t.dtstamp, dt(2018, 2, 18, 15, 47, 00, tzinfo=dateutil_tzutc)) - self.assertEqual(t.uid, 'Uid') - self.assertEqual(t.completed, dt(2018, 4, 18, 15, 00, 00, tzinfo=dateutil_tzutc)) - self.assertEqual(t.created, dt(2018, 2, 18, 15, 48, 00, tzinfo=dateutil_tzutc)) - self.assertEqual(t.description, 'Lorem ipsum dolor sit amet.') - self.assertEqual(t.begin, dt(2018, 2, 18, 16, 48, 00, tzinfo=dateutil_tzutc)) - self.assertEqual(t.location, 'Earth') - self.assertEqual(t.percent, 0) - self.assertEqual(t.priority, 0) - self.assertEqual(t.summary, 'Name') - self.assertEqual(t.url, 'https://www.example.com/cal.php/todo.ics') - self.assertEqual(t.duration, timedelta(minutes=10)) - self.assertEqual(len(t.alarms), 1) - - def test_extract_due(self): - c = Calendar(cal28) - t = next(iter(c.todos)) - self.assertEqual(t.due, dt(2018, 2, 18, 16, 48, 00, tzinfo=dateutil_tzutc)) - - def test_extract_due_error_duration(self): - with self.assertRaises(ValueError): - Calendar(cal29) - - def test_extract_duration_error_due(self): - with self.assertRaises(ValueError): - Calendar(cal30) - - def test_output(self): - c = Calendar(cal27) - t = next(iter(c.todos)) - - test_str = CRLF.join(("BEGIN:VTODO", - "SEQUENCE:0", - "BEGIN:VALARM", - "ACTION:DISPLAY", - "DESCRIPTION:Event reminder", - "TRIGGER:PT1H", - "END:VALARM", - "COMPLETED:20180418T150000Z", - "CREATED:20180218T154800Z", - "DESCRIPTION:Lorem ipsum dolor sit amet.", - "DTSTAMP:20180218T154700Z", - "DURATION:PT10M", - "LOCATION:Earth", - "PERCENT-COMPLETE:0", - "PRIORITY:0", - "DTSTART:20180218T164800Z", - "SUMMARY:Name", - "UID:Uid", - "URL:https://www.example.com/cal.php/todo.ics", - "END:VTODO")) - self.assertEqual(str(t), test_str) - - def test_output_due(self): - dtstamp = datetime(2018, 2, 19, 21, 00, tzinfo=datetime_tzutc) - due = datetime(2018, 2, 20, 1, 00, tzinfo=datetime_tzutc) - t = Todo(dtstamp=dtstamp, uid='Uid', due=due) - - test_str = CRLF.join(("BEGIN:VTODO", - "DTSTAMP:20180219T210000Z", - "DUE:20180220T010000Z", - "UID:Uid", - "END:VTODO")) - self.assertEqual(str(t), test_str) - - def test_unescape_texts(self): - c = Calendar(cal31) - t = next(iter(c.todos)) - self.assertEqual(t.summary, "Hello, \n World; This is a backslash : \\ and another new \n line") - self.assertEqual(t.location, "In, every text field") - self.assertEqual(t.description, "Yes, all of them;") - - def test_escape_output(self): - dtstamp = datetime(2018, 2, 19, 21, 00, tzinfo=datetime_tzutc) - t = Todo(dtstamp=dtstamp, uid='Uid') - - t.summary = "Hello, with \\ special; chars and \n newlines" - t.location = "Here; too" - t.description = "Every\nwhere ! Yes, yes !" - - test_str = CRLF.join(("BEGIN:VTODO", - "DESCRIPTION:Every\\nwhere ! Yes\\, yes !", - "DTSTAMP:20180219T210000Z", - "LOCATION:Here\\; too", - "SUMMARY:Hello\\, with \\\\ special\\; chars and \\n newlines", - "UID:Uid", - "END:VTODO")) - self.assertEqual(str(t), test_str) diff --git a/tests/unfold_lines.py b/tests/unfold_lines.py deleted file mode 100644 index f56e4b97..00000000 --- a/tests/unfold_lines.py +++ /dev/null @@ -1,62 +0,0 @@ -import unittest - -from ics.grammar.parse import unfold_lines - -from .fixture import (cal1, cal2, cal3, cal6, cal7, cal8, cal9, cal26, - unfolded_cal1, unfolded_cal2, unfolded_cal6, - unfolded_cal26) - - -class TestUnfoldLines(unittest.TestCase): - - def test_no_folded_lines(self): - self.assertEqual(list(unfold_lines(cal2.split('\n'))), unfolded_cal2) - - def test_simple_folded_lines(self): - self.assertEqual(list(unfold_lines(cal1.split('\n'))), unfolded_cal1) - - def test_last_line_folded(self): - self.assertEqual(list(unfold_lines(cal6.split('\n'))), unfolded_cal6) - - def test_tabbed_folding(self): - self.assertEqual(list(unfold_lines(cal26.split('\n'))), unfolded_cal26) - - def test_simple(self): - dataset = { - 'a': ('a',), - 'ab': ('ab',), - 'a\nb': ('a', 'b',), - 'a\n b': ('ab',), - 'a \n b': ('a b',), - 'a\n b\nc': ('ab', 'c',), - 'a\nb\n c': ('a', 'bc',), - 'a\nb\nc': ('a', 'b', 'c',), - 'a\n b\n c': ('abc',), - 'a \n b \n c': ('a b c',), - } - for line in dataset: - expected = dataset[line] - got = tuple(unfold_lines(line.split('\n'))) - self.assertEqual(expected, got) - - def test_empty(self): - self.assertEqual(list(unfold_lines([])), []) - - def test_one_line(self): - self.assertEqual(list(unfold_lines(cal6.split('\n'))), unfolded_cal6) - - def test_two_lines(self): - self.assertEqual(list(unfold_lines(cal3.split('\n'))), - ['BEGIN:VCALENDAR', 'END:VCALENDAR']) - - def test_no_empty_lines(self): - self.assertEqual(list(unfold_lines(cal7.split('\n'))), - ['BEGIN:VCALENDAR', 'END:VCALENDAR']) - - def test_no_whitespace_lines(self): - self.assertEqual(list(unfold_lines(cal8.split('\n'))), - ['BEGIN:VCALENDAR', 'END:VCALENDAR']) - - def test_first_line_empty(self): - self.assertEqual(list(unfold_lines(cal9.split('\n'))), - ['BEGIN:VCALENDAR', 'END:VCALENDAR']) diff --git a/tests/utils.py b/tests/utils.py deleted file mode 100644 index 0abf00f9..00000000 --- a/tests/utils.py +++ /dev/null @@ -1,89 +0,0 @@ -import unittest -from datetime import timedelta - -from ics.grammar.parse import ParseError, string_to_container -from ics.utils import (parse_datetime, parse_duration, remove_x, - serialize_duration) -from tests.fixture import cal1, cal2 - - -class TestParseDuration(unittest.TestCase): - dataset_simple = { - 'P1W': (7, 0), 'P1D': (1, 0), '-P1D': (-1, 0), - 'P1H': (0, 3600), 'P1M': (0, 60), 'P1S': (0, 1), - 'PT1H': (0, 3600), 'PT1M': (0, 60), 'PT1S': (0, 1), - 'PT': (0, 0) - } - - dataset_combined = { - "P1D1WT1H": (8, 3600), "P1DT1H1W": (8, 3600), "P1DT1H1M1W": (8, 3660), - "P1DT1H1M1S1W": (8, 3661), "P1DT1H": (1, 3600), "P1DT1H1M": (1, 3660), - "PT1S1M": (0, 61) - } - - def run_on_dataset(self, dataset): - for test in dataset: - expected = dataset[test] - self.assertEqual(parse_duration(test), timedelta(*expected)) - - def test_simple(self): - self.run_on_dataset(self.dataset_simple) - - def test_combined(self): - self.run_on_dataset(self.dataset_combined) - - def test_no_p(self): - self.assertRaises(ParseError, parse_duration, 'caca') - - def test_two_letters(self): - self.assertRaises(ParseError, parse_duration, 'P1DF') - - def test_two_occurences(self): - self.assertRaises(ParseError, parse_duration, 'P1D1D') - - -class TestTimedeltaToDuration(unittest.TestCase): - dataset_simple = { - # (0, 0): 'P', - (0, 0): 'PT0S', - (0, 1): 'PT1S', (0, 60): 'PT1M', (0, 3600): 'PT1H', - (1, 0): 'P1D', (7, 0): 'P7D', # (7, 0): 'P1W', - } - - dataset_combined = { - (1, 1): 'P1DT1S', - # (8, 3661): 'P1W1DT1H1M1S', (15, 18020): 'P2W1DT5H20S', - (8, 3661): 'P8DT1H1M1S', (15, 18020): 'P15DT5H20S', - } - - def run_on_dataset(self, dataset): - for test in dataset: - expected = dataset[test] - self.assertEqual(serialize_duration(timedelta(*test)), expected) - - def test_simple(self): - self.run_on_dataset(self.dataset_simple) - - def test_combined(self): - self.run_on_dataset(self.dataset_combined) - - -class TestRemoveX(unittest.TestCase): - - def test_with_x(self): - c = string_to_container(cal1)[0] - remove_x(c) - for line in c: - self.assertFalse(line.name.startswith('X-')) - - def test_without_x(self): - c = string_to_container(cal2)[0] - c2 = string_to_container(cal2)[0] - remove_x(c) - self.assertSequenceEqual(c, c2) - - -class Test_parse_datetime(unittest.TestCase): - - def test_none(self): - self.assertIs(None, parse_datetime(None)) From 3365faf6579dac23efddb0efcfaaefd29a690e7c Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Sun, 12 Apr 2020 12:27:10 +0200 Subject: [PATCH 33/43] add dev and publish instructions --- .gitignore | 1 + CONTRIBUTING.rst | 108 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/.gitignore b/.gitignore index ef0e8bfe..bae0a039 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /.idea /venv +/.venv # Created by https://www.gitignore.io/api/python # Edit at https://www.gitignore.io/?templates=python diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index a72be2cc..0fb2d588 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -33,6 +33,70 @@ you are solving it. This might save you a lot of time if the maintainers are already working on it or have a specific idea on how the problem should be solved. +Setting up the development environment +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +There are three python tools required to develop, test and release ics.py: +`poetry `_ for managing virtualenvs plus dependencies and for building plus publishing the package, +`tox `_ for running the testsuite and building the documentation, +and `bumpversion `_ to help with making a release. +Their respective configuration files are ``pyproject.toml``, ``tox.ini`` and ``.bumpversion.cfg``. +The ``poetry.lock`` file optionally locks the dependency versions against which we want to develop, +which is independent from the versions the library pulls in when installed as a dependency itself (where we are pretty liberal), +and the versions we test against (which is always the latest releases installed by tox). +You can simply install the tools via pip: + +.. code-block:: bash + + $ pip install tox poetry bumpversion --user + +.. note:: + If you want to develop using multiple different python versions, you might want to consider the + `poetry installer `_. + + Poetry will automatically manage a virtualenv that you can use for developing. + By default, it will be located centrally in your home directory (e.g. in ``/home/user/.cache/pypoetry/virtualenvs/``). + To make poetry use a ``./.venv/`` directory within the ics.py folder use the following config: + + .. code-block:: bash + + $ poetry config virtualenvs.in-project true + +Now you are ready to setup your development environment using the following command: + +.. code-block:: bash + + $ poetry install + +This will create a new virtualenv and install the dependencies for using ics.py. +Furthermore, the current source of the ics.py package will be available similar to running ``./setup.py develop``. +To access the virtualenv, simply use ``poetry run python`` or ``poetry shell``. +If you made some changes and now want to lint your code, run the testsuite, or build the documentation, simply call tox. +You don't have to worry about which versions in which venvs are installed and whether you're directly testing against the sources or against a built package, tox handles all that for you: + +.. code-block:: bash + + $ tox + +To just run a single task and not the whole testsuite, use the ``-e`` flag: + +.. code-block:: bash + + $ tox -e docs + +Checkout ``tox.ini`` to see which tasks are available. + +.. note:: + If you want to run any tasks of tox manually, you need to make sure that you also have all the dependencies of the task installed. + This is easily ensured by also installing the "dev" extra dependencies into you main environment: + + .. code-block:: bash + + $ poetry install --extras "dev" + $ poetry shell + (.venv) $ pytest + (.venv) $ cd doc && sphinx-build + If you are fixing a bug ^^^^^^^^^^^^^^^^^^^^^^^ @@ -66,3 +130,47 @@ Last thing The title of your PR will become the commit message, please craft it with care. + +How to make a new release +------------------------- + +If you want to hit the ground running and publish a new release on a freshly set-up machine, the following should suffice: + +.. code-block:: bash + + # Grab the sources and install the dev tools + git clone https://github.com/C4ptainCrunch/ics.py.git && cd ics.py + pip install tox poetry bumpversion --user + + # Make sure all the test run + tox && echo "Ready to make a new release" \ + || echo "Please fix all the tests first" + + # Bump the version and make a "0.8.0-dev -> 0.8.0 (release)" commit + bumpversion --verbose release + # Build the package + poetry build + # Ensure that the version numbers are consistent + tox --recreate + # Check changelog and amend if necessary + vi CHANGELOG.rst && git commit -i CHANGELOG.rst --amend + # Publish to GitHub + git push && git push --tags + # Publish to PyPi + poetry publish + + # Bump the version again to start development of next version + bumpversion --verbose minor # 0.8.0 (release) -> 0.9.0-dev + # Start new changelog + vi CHANGELOG.rst && git commit -i CHANGELOG.rst --amend + # Publish to GitHub + git push && git push --tags + +Please note that bumpversion directly makes a commit with the new version if you don't +pass ``--no-commit`` or ``--dry-run``, +but that's no problem as you can easily amend any changes you want to make. +Further things to check: + +* Check GitHub and PyPi release pages for obvious errors +* Build documentation for the tag v{version} on rtfd.org +* Set the default rtfd version to {version} From aa0447ecf9eeaaa256f8cf6e895ff9fd984dc525 Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Sun, 12 Apr 2020 14:26:16 +0200 Subject: [PATCH 34/43] checker happiness `noqa` and `type: ignore` are now only used for actual bugs in the checkers unfortunately, current pyflakes dislikes `type: ignore[something]`, so we can't ignore specific mypy bugs until pyflakes 2.2 is in flakes8 --- src/ics/__init__.py | 2 +- src/ics/alarm.py | 2 +- src/ics/component.py | 10 +++++----- src/ics/converter/component.py | 9 +++++++++ src/ics/converter/special.py | 4 +--- src/ics/converter/value.py | 6 +++--- src/ics/event.py | 4 ++-- src/ics/timespan.py | 34 +++++++++++++++++++++------------- src/ics/todo.py | 14 +++++++------- src/ics/types.py | 2 +- tox.ini | 1 + 11 files changed, 52 insertions(+), 36 deletions(-) diff --git a/src/ics/__init__.py b/src/ics/__init__.py index 91a2436a..ea024f6c 100644 --- a/src/ics/__init__.py +++ b/src/ics/__init__.py @@ -13,7 +13,7 @@ def load_converters(): load_converters() # make sure that converters are initialized before any Component classes are defined -from .alarm import * # noqa +from .alarm import * from .alarm import __all__ as all_alarms from .attendee import Attendee, Organizer from .component import Component diff --git a/src/ics/alarm.py b/src/ics/alarm.py index d74ca13c..1c262a97 100644 --- a/src/ics/alarm.py +++ b/src/ics/alarm.py @@ -25,7 +25,7 @@ class BaseAlarm(Component, metaclass=ABCMeta): trigger: Union[timedelta, datetime, None] = attr.ib( default=None, - validator=v_optional(instance_of((timedelta, datetime))) # type: ignore + validator=v_optional(instance_of((timedelta, datetime))) ) # TODO is this relative to begin or end? repeat: int = attr.ib(default=None, validator=call_validate_on_inst) duration: timedelta = attr.ib(default=None, converter=c_optional(ensure_timedelta), validator=call_validate_on_inst) # type: ignore diff --git a/src/ics/component.py b/src/ics/component.py index 6c52635b..f21c6269 100644 --- a/src/ics/component.py +++ b/src/ics/component.py @@ -3,7 +3,7 @@ import attr from attr.validators import instance_of -from ics.converter.component import ComponentMeta, InflatedComponentMeta +from ics.converter.component import ComponentMeta from ics.grammar import Container from ics.types import ExtraParams, RuntimeAttrValidation @@ -14,7 +14,7 @@ @attr.s class Component(RuntimeAttrValidation): - Meta: ClassVar[Union[ComponentMeta, InflatedComponentMeta]] = ComponentMeta("ABSTRACT-COMPONENT") + Meta: ClassVar[ComponentMeta] = ComponentMeta("ABSTRACT-COMPONENT") extra: Container = attr.ib(init=False, default=PLACEHOLDER_CONTAINER, validator=instance_of(Container), metadata={"ics_ignore": True}) extra_params: ComponentExtraParams = attr.ib(init=False, factory=dict, validator=instance_of(dict), metadata={"ics_ignore": True}) @@ -30,13 +30,13 @@ def __init_subclass__(cls): @classmethod def from_container(cls: Type[ComponentType], container: Container) -> ComponentType: - return cls.Meta.load_instance(container) # type: ignore + return cls.Meta.load_instance(container) def populate(self, container: Container): - self.Meta.populate_instance(self, container) # type: ignore + self.Meta.populate_instance(self, container) def to_container(self) -> Container: - return self.Meta.serialize_toplevel(self) # type: ignore + return self.Meta.serialize_toplevel(self) def serialize(self) -> str: return self.to_container().serialize() diff --git a/src/ics/converter/component.py b/src/ics/converter/component.py index 1baeadba..b37f5dd9 100644 --- a/src/ics/converter/component.py +++ b/src/ics/converter/component.py @@ -30,6 +30,15 @@ def inflate(self, component_type: Type["Component"]): container_name=self.container_name, converter_class=self.converter_class or ComponentConverter) + def load_instance(self, container: Container, context: Optional[ContextDict] = None): + raise NotImplementedError("this is only available for InflatedComponentMeta, was Component.__init_subclass__ called?") + + def populate_instance(self, instance: "Component", container: Container, context: Optional[ContextDict] = None): + raise NotImplementedError("this is only available for InflatedComponentMeta, was Component.__init_subclass__ called?") + + def serialize_toplevel(self, component: "Component", context: Optional[ContextDict] = None): + raise NotImplementedError("this is only available for InflatedComponentMeta, was Component.__init_subclass__ called?") + @attr.s(frozen=True) class InflatedComponentMeta(ComponentMeta): diff --git a/src/ics/converter/special.py b/src/ics/converter/special.py index aca62652..5d7a8e47 100644 --- a/src/ics/converter/special.py +++ b/src/ics/converter/special.py @@ -25,9 +25,7 @@ def populate(self, component: "Component", item: ContainerItem, context: Context self._check_component(component, context) item = item.clone([ - line for line in item if - not line.name.startswith("X-") and - not line.name == "SEQUENCE" + line for line in item if not line.name.startswith("X-") and not line.name == "SEQUENCE" ]) fake_file = StringIO() diff --git a/src/ics/converter/value.py b/src/ics/converter/value.py index 0deba572..dae59981 100644 --- a/src/ics/converter/value.py +++ b/src/ics/converter/value.py @@ -61,11 +61,11 @@ def populate(self, component: "Component", item: ContainerItem, context: Context context[(self, "current_value_count")] += 1 params = copy_extra_params(params) parsed = converter.parse(value, params, context) # might modify params and context - params["__merge_next"] = True # type: ignore + params["__merge_next"] = ["TRUE"] self.set_or_append_extra_params(component, params) self.set_or_append_value(component, parsed) if params is not None: - params["__merge_next"] = False # type: ignore + params["__merge_next"] = ["FALSE"] else: if context[(self, "current_value_count")] > 0: raise ValueError("attribute %s can only be set once, second occurrence is %s" % (self.ics_name, item)) @@ -116,7 +116,7 @@ def __serialize_multi(self, component: "Component", output: "Container", context for value, params in zip(values, extra_params): merge_next = False params = copy_extra_params(params) - if params.pop("__merge_next", False): # type: ignore + if params.pop("__merge_next", None) == ["TRUE"]: merge_next = True converter = self.__find_value_converter(params, value) serialized = converter.serialize(value, params, context) # might modify params and context diff --git a/src/ics/event.py b/src/ics/event.py index 2ee9f8c2..335f5e6a 100644 --- a/src/ics/event.py +++ b/src/ics/event.py @@ -28,7 +28,7 @@ class CalendarEntryAttrs(Component): description: Optional[str] = attr.ib(default=None) location: Optional[str] = attr.ib(default=None) url: Optional[str] = attr.ib(default=None) - status: Optional[str] = attr.ib(default=None, converter=c_optional(str.upper), validator=v_optional(in_(STATUS_VALUES))) # type: ignore + status: Optional[str] = attr.ib(default=None, converter=c_optional(str.upper), validator=in_(STATUS_VALUES)) # type: ignore created: Optional[datetime] = attr.ib(default=None, converter=ensure_utc) # type: ignore last_modified: Optional[datetime] = attr.ib(default=None, converter=ensure_utc) # type: ignore @@ -209,7 +209,7 @@ class EventAttrs(CalendarEntryAttrs): transparent: Optional[bool] = attr.ib(default=None) organizer: Optional[Organizer] = attr.ib(default=None, validator=v_optional(instance_of(Organizer))) - geo: Optional[Geo] = attr.ib(default=None, converter=make_geo) # type: ignore + geo: Optional[Geo] = attr.ib(default=None, converter=make_geo) attendees: List[Attendee] = attr.ib(factory=list, converter=list) categories: List[str] = attr.ib(factory=list, converter=list) diff --git a/src/ics/timespan.py b/src/ics/timespan.py index b57a0f09..35c03c10 100644 --- a/src/ics/timespan.py +++ b/src/ics/timespan.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta, tzinfo as TZInfo -from typing import Any, Callable, NamedTuple, Optional, TypeVar, Union, cast, overload +from typing import Any, Callable, NamedTuple, Optional, TYPE_CHECKING, TypeVar, Union, cast, overload import attr from attr.validators import instance_of, optional as v_optional @@ -8,6 +8,11 @@ from ics.types import DatetimeLike from ics.utils import TIMEDELTA_CACHE, TIMEDELTA_DAY, TIMEDELTA_ZERO, ceil_datetime_to_midnight, ensure_datetime, floor_datetime_to_midnight, timedelta_nearly_zero +if TYPE_CHECKING: + # Literal is new in python 3.8, but backported via typing_extensions + # we don't need typing_extensions as actual (dev-)dependency as mypy has builtin support + from typing_extensions import Literal + @attr.s class Normalization(object): @@ -15,19 +20,19 @@ class Normalization(object): normalize_with_tz: bool = attr.ib() replacement: Union[TZInfo, Callable[[], TZInfo], None] = attr.ib() - @overload + @overload # noqa: F811 # pyflakes < 2.2 reports 'redefinition of unused' for overloaded class members def normalize(self, value: "Timespan") -> "Timespan": ... - @overload # noqa + @overload # noqa: F811 def normalize(self, value: DatetimeLike) -> datetime: ... - @overload # noqa + @overload # noqa: F811 def normalize(self, value: None) -> None: ... - def normalize(self, value): # noqa + def normalize(self, value): # noqa: F811 """ Normalize datetime or timespan instances to make naive/floating ones (without timezone, i.e. tzinfo == None) comparable to aware ones with a fixed timezone. @@ -80,10 +85,10 @@ def __attrs_post_init__(self): def replace( self: TimespanT, - begin_time: Optional[datetime] = False, # type: ignore - end_time: Optional[datetime] = False, # type: ignore - duration: Optional[timedelta] = False, # type: ignore - precision: str = False # type: ignore + begin_time: Union[datetime, None, "Literal[False]"] = False, + end_time: Union[datetime, None, "Literal[False]"] = False, + duration: Union[timedelta, None, "Literal[False]"] = False, + precision: Union[str, "Literal[False]"] = False ) -> TimespanT: if begin_time is False: begin_time = self.begin_time @@ -93,7 +98,10 @@ def replace( duration = self.duration if precision is False: precision = self.precision - return type(self)(begin_time=begin_time, end_time=end_time, duration=duration, precision=precision) + return type(self)(begin_time=cast(Optional[datetime], begin_time), + end_time=cast(Optional[datetime], end_time), + duration=cast(Optional[timedelta], duration), + precision=cast(str, precision)) def replace_timezone(self: TimespanT, tzinfo: Optional[TZInfo]) -> TimespanT: if self.is_all_day(): @@ -298,15 +306,15 @@ def has_explicit_end(self) -> bool: #################################################################################################################### - @overload + @overload # noqa: F811 def timespan_tuple(self, default: None = None, normalization: Normalization = None) -> NullableTimespanTuple: ... - @overload # noqa + @overload # noqa: F811 def timespan_tuple(self, default: datetime, normalization: Normalization = None) -> TimespanTuple: ... - def timespan_tuple(self, default=None, normalization=None): # noqa + def timespan_tuple(self, default=None, normalization=None): # noqa: F811 if normalization: return TimespanTuple( normalization.normalize(self.get_begin() or default), diff --git a/src/ics/todo.py b/src/ics/todo.py index 801211f9..16a03b22 100644 --- a/src/ics/todo.py +++ b/src/ics/todo.py @@ -37,7 +37,7 @@ def wrapper(*args, **kwargs): class TodoAttrs(CalendarEntryAttrs): percent: Optional[int] = attr.ib(default=None, validator=v_optional(in_(range(0, MAX_PERCENT + 1)))) priority: Optional[int] = attr.ib(default=None, validator=v_optional(in_(range(0, MAX_PRIORITY + 1)))) - completed: Optional[datetime] = attr.ib(default=None, converter=ensure_datetime) # type: ignore + completed: Optional[datetime] = attr.ib(default=None, converter=ensure_datetime) class Todo(TodoAttrs): @@ -69,14 +69,14 @@ def convert_due(self, representation): representation = "end" super(Todo, self).convert_end(representation) - due = property(TodoAttrs.end.fget, TodoAttrs.end.fset) # type: ignore + due = property(TodoAttrs.end.fget, TodoAttrs.end.fset) # convert_due = TodoAttrs.convert_end # see above - due_representation = property(TodoAttrs.end_representation.fget) # type: ignore - has_explicit_due = property(TodoAttrs.has_explicit_end.fget) # type: ignore + due_representation = property(TodoAttrs.end_representation.fget) + has_explicit_due = property(TodoAttrs.has_explicit_end.fget) due_within = TodoAttrs.ends_within - end = property(deprecated_due(TodoAttrs.end.fget), deprecated_due(TodoAttrs.end.fset)) # type: ignore + end = property(deprecated_due(TodoAttrs.end.fget), deprecated_due(TodoAttrs.end.fset)) convert_end = deprecated_due(TodoAttrs.convert_end) - end_representation = property(deprecated_due(TodoAttrs.end_representation.fget)) # type: ignore - has_explicit_end = property(deprecated_due(TodoAttrs.has_explicit_end.fget)) # type: ignore + end_representation = property(deprecated_due(TodoAttrs.end_representation.fget)) + has_explicit_end = property(deprecated_due(TodoAttrs.has_explicit_end.fget)) ends_within = deprecated_due(TodoAttrs.ends_within) diff --git a/src/ics/types.py b/src/ics/types.py index 2ae68d15..a194c352 100644 --- a/src/ics/types.py +++ b/src/ics/types.py @@ -71,7 +71,7 @@ def get_timespan_if_calendar_entry(value: None) -> None: def get_timespan_if_calendar_entry(value): - from ics.event import CalendarEntryAttrs # noqa + from ics.event import CalendarEntryAttrs # noqa: F811 # pyflakes considers this a redef of the unused if TYPE_CHECKING import above if isinstance(value, CalendarEntryAttrs): return value._timespan diff --git a/tox.ini b/tox.ini index 05cfce59..54f13690 100644 --- a/tox.ini +++ b/tox.ini @@ -70,3 +70,4 @@ ignore = [mypy] python_version = 3.6 warn_unused_configs = True +show_error_codes = True From 83bda5cc412c292c893506cfce225ce6d5b0d93e Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Sun, 12 Apr 2020 14:52:01 +0200 Subject: [PATCH 35/43] more checker happiness --- src/ics/alarm.py | 2 +- src/ics/converter/base.py | 10 +++++----- src/ics/timespan.py | 17 +++++++++-------- src/ics/valuetype/base.py | 8 ++++---- tests/grammar/__init__.py | 2 +- tox.ini | 29 ++++++++++++++++------------- 6 files changed, 36 insertions(+), 32 deletions(-) diff --git a/src/ics/alarm.py b/src/ics/alarm.py index 1c262a97..5bc3ca08 100644 --- a/src/ics/alarm.py +++ b/src/ics/alarm.py @@ -50,7 +50,7 @@ def validate(self, attr=None, value=None): @abstractmethod def action(self): """ VALARM action to be implemented by concrete classes """ - pass + ... @attr.s diff --git a/src/ics/converter/base.py b/src/ics/converter/base.py index cd9cf90e..e405c57e 100644 --- a/src/ics/converter/base.py +++ b/src/ics/converter/base.py @@ -21,12 +21,12 @@ class GenericConverter(abc.ABC): @property @abc.abstractmethod def priority(self) -> int: - pass + ... @property @abc.abstractmethod def filter_ics_names(self) -> List[str]: - pass + ... @abc.abstractmethod def populate(self, component: "Component", item: ContainerItem, context: ContextDict) -> bool: @@ -36,14 +36,14 @@ def populate(self, component: "Component", item: ContainerItem, context: Context :param item: :return: True, if the line was consumed and shouldn't be stored as extra (but might still be passed on) """ - pass + ... def finalize(self, component: "Component", context: ContextDict): - pass + ... @abc.abstractmethod def serialize(self, component: "Component", output: Container, context: ContextDict): - pass + ... @attr.s(frozen=True) diff --git a/src/ics/timespan.py b/src/ics/timespan.py index 35c03c10..7447dd61 100644 --- a/src/ics/timespan.py +++ b/src/ics/timespan.py @@ -20,16 +20,17 @@ class Normalization(object): normalize_with_tz: bool = attr.ib() replacement: Union[TZInfo, Callable[[], TZInfo], None] = attr.ib() - @overload # noqa: F811 # pyflakes < 2.2 reports 'redefinition of unused' for overloaded class members + @overload def normalize(self, value: "Timespan") -> "Timespan": ... - @overload # noqa: F811 - def normalize(self, value: DatetimeLike) -> datetime: + # pyflakes < 2.2 reports 'redefinition of unused' for overloaded class members + @overload + def normalize(self, value: DatetimeLike) -> datetime: # noqa: F811 ... - @overload # noqa: F811 - def normalize(self, value: None) -> None: + @overload + def normalize(self, value: None) -> None: # noqa: F811 ... def normalize(self, value): # noqa: F811 @@ -306,12 +307,12 @@ def has_explicit_end(self) -> bool: #################################################################################################################### - @overload # noqa: F811 + @overload def timespan_tuple(self, default: None = None, normalization: Normalization = None) -> NullableTimespanTuple: ... - @overload # noqa: F811 - def timespan_tuple(self, default: datetime, normalization: Normalization = None) -> TimespanTuple: + @overload + def timespan_tuple(self, default: datetime, normalization: Normalization = None) -> TimespanTuple: # noqa: F811 ... def timespan_tuple(self, default=None, normalization=None): # noqa: F811 diff --git a/src/ics/valuetype/base.py b/src/ics/valuetype/base.py index 883b84c6..7ac354a7 100644 --- a/src/ics/valuetype/base.py +++ b/src/ics/valuetype/base.py @@ -23,12 +23,12 @@ def __init_subclass__(cls) -> None: @property @abc.abstractmethod def ics_type(self) -> str: - pass + ... @property @abc.abstractmethod def python_type(self) -> Type[T]: - pass + ... def split_value_list(self, values: str) -> Iterable[str]: yield from values.split(",") @@ -38,11 +38,11 @@ def join_value_list(self, values: Iterable[str]) -> str: @abc.abstractmethod def parse(self, value: str, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> T: - pass + ... @abc.abstractmethod def serialize(self, value: T, params: ExtraParams = EmptyParams, context: ContextDict = EmptyContext) -> str: - pass + ... def __str__(self): return "<" + self.__class__.__name__ + ">" diff --git a/tests/grammar/__init__.py b/tests/grammar/__init__.py index 799adb70..36ff73eb 100644 --- a/tests/grammar/__init__.py +++ b/tests/grammar/__init__.py @@ -6,7 +6,7 @@ from ics.grammar import Container, ContentLine, ParseError, QuotedParamValue, escape_param, string_to_container, unfold_lines -CONTROL = [chr(i) for i in range(ord(" ")) if i != ord("\t")] +CONTROL = [chr(i) for i in range(ord(" ")) if i != ord("\t")] + [chr(0x7F)] NAME = text(alphabet=(characters(whitelist_categories=["Lu"], whitelist_characters=["-"], max_codepoint=128)), min_size=1) VALUE = text(characters(blacklist_categories=["Cs"], blacklist_characters=CONTROL)) diff --git a/tox.ini b/tox.ini index 54f13690..ccbcf7d4 100644 --- a/tox.ini +++ b/tox.ini @@ -11,19 +11,22 @@ commands = pytest --basetemp="{envtmpdir}" {posargs} [testenv:flake8] +basepython = python3.8 commands = flake8 --version flake8 src/ [testenv:mypy] +basepython = python3.8 commands = mypy -V mypy --config-file=tox.ini src/ [testenv:docs] -commands = +basepython = python3.8 +commands = sphinx-build -d "{toxworkdir}/docs_doctree" doc "{toxworkdir}/docs_out" --color -bhtml {posargs} - python -c 'import pathlib; print("documentation available under file://\{0\}".format(pathlib.Path(r"{toxworkdir}") / "docs_out" / "index.html"))' + python -c 'import pathlib; print("documentation available under file://\{0\}".format(pathlib.Path(r"{toxworkdir}") / "docs_out" / "index.html"))' [gh-actions] python = @@ -35,7 +38,7 @@ python = python_files = *.py norecursedirs = dist venv .git .hypothesis .mypy_cache .pytest_cache .tox .eggs .cache ics.egg-info testpaths = doc tests -addopts = +addopts = --doctest-glob='*.rst' --doctest-modules --ignore doc/conf.py --hypothesis-show-statistics @@ -46,25 +49,25 @@ addopts = # http://flake8.pycqa.org/en/latest/user/error-codes.html # https://pycodestyle.readthedocs.io/en/latest/intro.html#error-codes ignore = - # E127 continuation line over-indented for visual indent +# E127 continuation line over-indented for visual indent E127 - # E128 continuation line under-indented for visual indent +# E128 continuation line under-indented for visual indent E128 - # E251 unexpected spaces around keyword / parameter equals +# E251 unexpected spaces around keyword / parameter equals E251 - # E402 module level import not at top of file +# E402 module level import not at top of file E402 - # E501 line too long (82 > 79 characters) +# E501 line too long (82 > 79 characters) E501 - # E701 multiple statements on one line (colon) +# E701 multiple statements on one line (colon) E701 - # E704 multiple statements on one line (def) +# E704 multiple statements on one line (def) E704 - # E731 do not assign a lambda expression, use a def +# E731 do not assign a lambda expression, use a def E731 - # F401 module imported but unused +# F401 module imported but unused F401 - # F403 ‘from module import *’ used; unable to detect undefined names +# F403 ‘from module import *’ used; unable to detect undefined names F403 [mypy] From 4927e0f0868e31299f0327b2fc6bbb5157b903ee Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Tue, 21 Apr 2020 18:11:37 +0200 Subject: [PATCH 36/43] Apply suggestions from code review Co-Authored-By: Tom Schraitle --- CONTRIBUTING.rst | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 0fb2d588..7377e0d2 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -33,30 +33,32 @@ you are solving it. This might save you a lot of time if the maintainers are already working on it or have a specific idea on how the problem should be solved. -Setting up the development environment +Setting up the Development Environment ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -There are three python tools required to develop, test and release ics.py: -`poetry `_ for managing virtualenvs plus dependencies and for building plus publishing the package, -`tox `_ for running the testsuite and building the documentation, -and `bumpversion `_ to help with making a release. -Their respective configuration files are ``pyproject.toml``, ``tox.ini`` and ``.bumpversion.cfg``. +There are three Python tools required to develop, test, and release ics.py: + +* `poetry `_ for managing virtualenvs, dependencies, building, and publishing the package. +* `tox `_ for running the testsuite and building the documentation. +* `bumpversion `_ to help with making a release. + +Their respective configuration files are :file:`pyproject.toml`, :file:`tox.ini` and :file:`.bumpversion.cfg`. The ``poetry.lock`` file optionally locks the dependency versions against which we want to develop, which is independent from the versions the library pulls in when installed as a dependency itself (where we are pretty liberal), and the versions we test against (which is always the latest releases installed by tox). -You can simply install the tools via pip: +Install the tools via pip: .. code-block:: bash $ pip install tox poetry bumpversion --user .. note:: - If you want to develop using multiple different python versions, you might want to consider the + If you want to develop using multiple different Python versions, you might want to consider the `poetry installer `_. Poetry will automatically manage a virtualenv that you can use for developing. By default, it will be located centrally in your home directory (e.g. in ``/home/user/.cache/pypoetry/virtualenvs/``). - To make poetry use a ``./.venv/`` directory within the ics.py folder use the following config: + To make poetry use a ``./.venv/`` directory within the ics.py folder, use the following config: .. code-block:: bash @@ -70,21 +72,21 @@ Now you are ready to setup your development environment using the following comm This will create a new virtualenv and install the dependencies for using ics.py. Furthermore, the current source of the ics.py package will be available similar to running ``./setup.py develop``. -To access the virtualenv, simply use ``poetry run python`` or ``poetry shell``. -If you made some changes and now want to lint your code, run the testsuite, or build the documentation, simply call tox. +To access the virtualenv, use ``poetry run python`` or ``poetry shell``. +If you made some changes and now want to lint your code, run the testsuite, or build the documentation, run tox. You don't have to worry about which versions in which venvs are installed and whether you're directly testing against the sources or against a built package, tox handles all that for you: .. code-block:: bash $ tox -To just run a single task and not the whole testsuite, use the ``-e`` flag: +To run a single task and not the whole testsuite, use the ``-e`` flag: .. code-block:: bash $ tox -e docs -Checkout ``tox.ini`` to see which tasks are available. +To get a list of all available tasks, run :command:`tox -av`. .. note:: If you want to run any tasks of tox manually, you need to make sure that you also have all the dependencies of the task installed. @@ -134,7 +136,7 @@ Last thing How to make a new release ------------------------- -If you want to hit the ground running and publish a new release on a freshly set-up machine, the following should suffice: +If you want to publish a new release, use the following steps .. code-block:: bash From eff8add6fc5d7dfc875473c029e7f8e5d74f5c19 Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Wed, 22 Apr 2020 17:18:21 +0200 Subject: [PATCH 37/43] use gitignore directly from github instead of gitignore.io --- .gitignore | 55 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index bae0a039..b27fc4d7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,10 +2,6 @@ /venv /.venv -# Created by https://www.gitignore.io/api/python -# Edit at https://www.gitignore.io/?templates=python - -### Python ### # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -28,7 +24,6 @@ parts/ sdist/ var/ wheels/ -pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg @@ -55,13 +50,25 @@ htmlcov/ nosetests.xml coverage.xml *.cover +*.py,cover .hypothesis/ .pytest_cache/ +cover/ # Translations *.mo *.pot +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + # Scrapy stuff: .scrapy @@ -69,10 +76,20 @@ coverage.xml docs/_build/ # PyBuilder +.pybuilder/ target/ +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + # pyenv -.python-version +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. @@ -81,12 +98,25 @@ target/ # install all needed dependencies. #Pipfile.lock -# celery beat schedule file +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff celerybeat-schedule +celerybeat.pid # SageMath parsed files *.sage.py +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + # Spyder project settings .spyderproject .spyproject @@ -94,11 +124,6 @@ celerybeat-schedule # Rope project settings .ropeproject -# Mr Developer -.mr.developer.cfg -.project -.pydevproject - # mkdocs documentation /site @@ -110,4 +135,8 @@ dmypy.json # Pyre type checker .pyre/ -# End of https://www.gitignore.io/api/python +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ From 6c4169a85faae872af6dee39f20979e24e7e3ac1 Mon Sep 17 00:00:00 2001 From: Niko Fink Date: Wed, 22 Apr 2020 18:03:44 +0200 Subject: [PATCH 38/43] Apply suggestions from code review to tox.ini --- tox.ini | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index ccbcf7d4..67726796 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,9 @@ [tox] isolated_build = true -envlist = py36, py37, py38, flake8, mypy, docs +envlist = py36, py37, py38, checks, docs [testenv] +description = Run the pytest suite extras = dev commands = @@ -11,18 +12,31 @@ commands = pytest --basetemp="{envtmpdir}" {posargs} [testenv:flake8] +description = Run the flake8 code style checks basepython = python3.8 commands = flake8 --version flake8 src/ [testenv:mypy] +description = Run the mypy type checks basepython = python3.8 commands = mypy -V mypy --config-file=tox.ini src/ +[testenv:checks] +description = Run all code checkers (flake8 and mypy) +basepython = python3 +deps = + {[testenv:flake8]deps} + {[testenv:mypy]deps} +commands = + {[testenv:flake8]commands} + {[testenv:mypy]commands} + [testenv:docs] +description = Build the documentation with sphinx basepython = python3.8 commands = sphinx-build -d "{toxworkdir}/docs_doctree" doc "{toxworkdir}/docs_out" --color -bhtml {posargs} @@ -71,6 +85,6 @@ ignore = F403 [mypy] -python_version = 3.6 +python_version = 3.8 warn_unused_configs = True show_error_codes = True From 547b2411de3c2a3bc106112770ecb3b432c80cc3 Mon Sep 17 00:00:00 2001 From: Simon Dominik Niko Fink Date: Wed, 22 Apr 2020 19:54:49 +0200 Subject: [PATCH 39/43] fix tox.ini --- tox.ini | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index 67726796..8ac8d77e 100644 --- a/tox.ini +++ b/tox.ini @@ -28,9 +28,8 @@ commands = [testenv:checks] description = Run all code checkers (flake8 and mypy) basepython = python3 -deps = - {[testenv:flake8]deps} - {[testenv:mypy]deps} +# deps are specified on 'testenv' for all commands +# deps = {[testenv:flake8]deps} {[testenv:mypy]deps} commands = {[testenv:flake8]commands} {[testenv:mypy]commands} From 9bc16c530773bc7ae237ca9f4f9d4bd1ebad12ca Mon Sep 17 00:00:00 2001 From: Simon Dominik Niko Fink Date: Sun, 10 May 2020 20:03:04 +0200 Subject: [PATCH 40/43] add pypy support Mostly by moving/splitting test dependencies to different sections in tox.ini as mypy and pypy don't work well together and it is sufficient to run mypy checks on CPython. --- doc/event-cmp.rst | 6 +- poetry.lock | 811 +------------------------------------- pyproject.toml | 15 - src/ics/converter/base.py | 19 +- tox.ini | 25 +- 5 files changed, 33 insertions(+), 843 deletions(-) diff --git a/doc/event-cmp.rst b/doc/event-cmp.rst index f532676b..2c9bc03e 100644 --- a/doc/event-cmp.rst +++ b/doc/event-cmp.rst @@ -69,7 +69,7 @@ attributes. Event(extra=Container('VEVENT', []), extra_params={}, _timespan=EventTimespan(begin_time=None, end_time=None, duration=None, precision='second'), summary=None, uid='...@....org', description=None, location=None, url=None, status=None, created=None, last_modified=None, dtstamp=datetime.datetime(2020, ..., tzinfo=tzutc()), alarms=[], attach=[], classification=None, transparent=None, organizer=None, geo=None, attendees=[], categories=[]) >>> str(e) '' - >>> e.serialize() # doctest: +ELLIPSIS + >>> e.serialize() # doctest: +ELLIPSIS 'BEGIN:VEVENT\r\nUID:...@...org\r\nDTSTAMP:2020...T...Z\r\nEND:VEVENT' >>> import attr, pprint >>> pprint.pprint(attr.asdict(e)) # doctest: +ELLIPSIS @@ -274,7 +274,7 @@ compare naive ones with timezone-aware ones: :: >>> dt_naive = dt(2020, 2, 20, 20, 20) - >>> dt_naive < dt_local + >>> dt_naive < dt_local # doctest: +IGNORE_EXCEPTION_DETAIL Traceback (most recent call last): ... TypeError: can't compare offset-naive and offset-aware datetimes @@ -340,7 +340,7 @@ compared: False >>> e_local > e_floating False - >>> e_local.begin < e_floating.begin + >>> e_local.begin < e_floating.begin # doctest: +IGNORE_EXCEPTION_DETAIL Traceback (most recent call last): ... TypeError: can't compare offset-naive and offset-aware datetimes diff --git a/poetry.lock b/poetry.lock index 54893c6d..602f7c3b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,20 +1,3 @@ -[[package]] -category = "main" -description = "A configurable sidebar-enabled Sphinx theme" -name = "alabaster" -optional = true -python-versions = "*" -version = "0.7.12" - -[[package]] -category = "main" -description = "Atomic file writes." -marker = "sys_platform == \"win32\"" -name = "atomicwrites" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.3.0" - [[package]] category = "main" description = "Classes Without Boilerplate" @@ -29,122 +12,6 @@ dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.int docs = ["sphinx", "zope.interface"] tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] -[[package]] -category = "main" -description = "Internationalization utilities" -name = "babel" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.8.0" - -[package.dependencies] -pytz = ">=2015.7" - -[[package]] -category = "main" -description = "Python package for providing Mozilla's CA Bundle." -name = "certifi" -optional = true -python-versions = "*" -version = "2020.4.5.1" - -[[package]] -category = "main" -description = "Universal encoding detector for Python 2 and 3" -name = "chardet" -optional = true -python-versions = "*" -version = "3.0.4" - -[[package]] -category = "main" -description = "Cross-platform colored terminal text." -marker = "sys_platform == \"win32\"" -name = "colorama" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "0.4.3" - -[[package]] -category = "main" -description = "Code coverage measurement for Python" -name = "coverage" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" -version = "5.0.4" - -[package.extras] -toml = ["toml"] - -[[package]] -category = "main" -description = "Docutils -- Python Documentation Utilities" -name = "docutils" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "0.16" - -[[package]] -category = "main" -description = "Discover and load entry points from installed packages." -name = "entrypoints" -optional = true -python-versions = ">=2.7" -version = "0.3" - -[[package]] -category = "main" -description = "the modular source code checker: pep8, pyflakes and co" -name = "flake8" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "3.7.9" - -[package.dependencies] -entrypoints = ">=0.3.0,<0.4.0" -mccabe = ">=0.6.0,<0.7.0" -pycodestyle = ">=2.5.0,<2.6.0" -pyflakes = ">=2.1.0,<2.2.0" - -[[package]] -category = "main" -description = "A library for property-based testing" -name = "hypothesis" -optional = true -python-versions = ">=3.5.2" -version = "5.8.0" - -[package.dependencies] -attrs = ">=19.2.0" -sortedcontainers = ">=2.1.0,<3.0.0" - -[package.extras] -all = ["django (>=1.11)", "dpcontracts (>=0.4)", "lark-parser (>=0.6.5)", "numpy (>=1.9.0)", "pandas (>=0.19)", "pytest (>=4.3)", "python-dateutil (>=1.4)", "pytz (>=2014.1)"] -dateutil = ["python-dateutil (>=1.4)"] -django = ["pytz (>=2014.1)", "django (>=1.11)"] -dpcontracts = ["dpcontracts (>=0.4)"] -lark = ["lark-parser (>=0.6.5)"] -numpy = ["numpy (>=1.9.0)"] -pandas = ["pandas (>=0.19)"] -pytest = ["pytest (>=4.3)"] -pytz = ["pytz (>=2014.1)"] - -[[package]] -category = "main" -description = "Internationalized Domain Names in Applications (IDNA)" -name = "idna" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.9" - -[[package]] -category = "main" -description = "Getting image size from png/jpeg/jpeg2000/gif file" -name = "imagesize" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.2.0" - [[package]] category = "main" description = "Read metadata from Python packages" @@ -167,7 +34,7 @@ description = "Read resources from Python packages" name = "importlib-resources" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" -version = "1.4.0" +version = "1.5.0" [package.dependencies] [package.dependencies.importlib-metadata] @@ -181,188 +48,6 @@ version = ">=0.4" [package.extras] docs = ["sphinx", "rst.linker", "jaraco.packaging"] -[[package]] -category = "main" -description = "A very fast and expressive template engine." -name = "jinja2" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.11.1" - -[package.dependencies] -MarkupSafe = ">=0.23" - -[package.extras] -i18n = ["Babel (>=0.8)"] - -[[package]] -category = "main" -description = "Safely add untrusted strings to HTML/XML markup." -name = "markupsafe" -optional = true -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" -version = "1.1.1" - -[[package]] -category = "main" -description = "McCabe checker, plugin for flake8" -name = "mccabe" -optional = true -python-versions = "*" -version = "0.6.1" - -[[package]] -category = "main" -description = "More routines for operating on iterables, beyond itertools" -name = "more-itertools" -optional = true -python-versions = ">=3.5" -version = "8.2.0" - -[[package]] -category = "main" -description = "Optional static typing for Python" -name = "mypy" -optional = true -python-versions = ">=3.5" -version = "0.770" - -[package.dependencies] -mypy-extensions = ">=0.4.3,<0.5.0" -typed-ast = ">=1.4.0,<1.5.0" -typing-extensions = ">=3.7.4" - -[package.extras] -dmypy = ["psutil (>=4.0)"] - -[[package]] -category = "main" -description = "Experimental type system extensions for programs checked with the mypy typechecker." -name = "mypy-extensions" -optional = true -python-versions = "*" -version = "0.4.3" - -[[package]] -category = "main" -description = "Core utilities for Python packages" -name = "packaging" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "20.3" - -[package.dependencies] -pyparsing = ">=2.0.2" -six = "*" - -[[package]] -category = "main" -description = "plugin and hook calling mechanisms for python" -name = "pluggy" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "0.13.1" - -[package.dependencies] -[package.dependencies.importlib-metadata] -python = "<3.8" -version = ">=0.12" - -[package.extras] -dev = ["pre-commit", "tox"] - -[[package]] -category = "main" -description = "A collection of helpful Python tools!" -name = "pockets" -optional = true -python-versions = "*" -version = "0.9.1" - -[package.dependencies] -six = ">=1.5.2" - -[[package]] -category = "main" -description = "library with cross-python path, ini-parsing, io, code, log facilities" -name = "py" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.8.1" - -[[package]] -category = "main" -description = "Python style guide checker" -name = "pycodestyle" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.5.0" - -[[package]] -category = "main" -description = "passive checker of Python programs" -name = "pyflakes" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.1.1" - -[[package]] -category = "main" -description = "Pygments is a syntax highlighting package written in Python." -name = "pygments" -optional = true -python-versions = ">=3.5" -version = "2.6.1" - -[[package]] -category = "main" -description = "Python parsing module" -name = "pyparsing" -optional = true -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -version = "2.4.7" - -[[package]] -category = "main" -description = "pytest: simple powerful testing with Python" -name = "pytest" -optional = true -python-versions = ">=3.5" -version = "5.4.1" - -[package.dependencies] -atomicwrites = ">=1.0" -attrs = ">=17.4.0" -colorama = "*" -more-itertools = ">=4.0.0" -packaging = "*" -pluggy = ">=0.12,<1.0" -py = ">=1.5.0" -wcwidth = "*" - -[package.dependencies.importlib-metadata] -python = "<3.8" -version = ">=0.12" - -[package.extras] -checkqa-mypy = ["mypy (v0.761)"] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] - -[[package]] -category = "main" -description = "Pytest plugin for measuring coverage." -name = "pytest-cov" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.8.1" - -[package.dependencies] -coverage = ">=4.4" -pytest = ">=3.6" - -[package.extras] -testing = ["fields", "hunter", "process-tests (2.0.2)", "six", "virtualenv"] - [[package]] category = "main" description = "Extensions to the standard Python datetime module" @@ -374,32 +59,6 @@ version = "2.8.1" [package.dependencies] six = ">=1.5" -[[package]] -category = "main" -description = "World timezone definitions, modern and historical" -name = "pytz" -optional = true -python-versions = "*" -version = "2019.3" - -[[package]] -category = "main" -description = "Python HTTP for Humans." -name = "requests" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.23.0" - -[package.dependencies] -certifi = ">=2017.4.17" -chardet = ">=3.0.2,<4" -idna = ">=2.5,<3" -urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" - -[package.extras] -security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] -socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] - [[package]] category = "main" description = "Python 2 and 3 compatibility utilities" @@ -408,152 +67,6 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" version = "1.14.0" -[[package]] -category = "main" -description = "This package provides 26 stemmers for 25 languages generated from Snowball algorithms." -name = "snowballstemmer" -optional = true -python-versions = "*" -version = "2.0.0" - -[[package]] -category = "main" -description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" -name = "sortedcontainers" -optional = true -python-versions = "*" -version = "2.1.0" - -[[package]] -category = "main" -description = "Python documentation generator" -name = "sphinx" -optional = true -python-versions = ">=3.5" -version = "3.0.1" - -[package.dependencies] -Jinja2 = ">=2.3" -Pygments = ">=2.0" -alabaster = ">=0.7,<0.8" -babel = ">=1.3" -colorama = ">=0.3.5" -docutils = ">=0.12" -imagesize = "*" -packaging = "*" -requests = ">=2.5.0" -setuptools = "*" -snowballstemmer = ">=1.1" -sphinxcontrib-applehelp = "*" -sphinxcontrib-devhelp = "*" -sphinxcontrib-htmlhelp = "*" -sphinxcontrib-jsmath = "*" -sphinxcontrib-qthelp = "*" -sphinxcontrib-serializinghtml = "*" - -[package.extras] -docs = ["sphinxcontrib-websupport"] -lint = ["flake8 (>=3.5.0)", "flake8-import-order", "mypy (>=0.770)", "docutils-stubs"] -test = ["pytest", "pytest-cov", "html5lib", "typed-ast", "cython"] - -[[package]] -category = "main" -description = "Type hints (PEP 484) support for the Sphinx autodoc extension" -name = "sphinx-autodoc-typehints" -optional = true -python-versions = ">=3.5.2" -version = "1.10.3" - -[package.dependencies] -Sphinx = ">=2.1" - -[package.extras] -test = ["pytest (>=3.1.0)", "typing-extensions (>=3.5)", "sphobjinv (>=2.0)", "dataclasses"] -type_comments = ["typed-ast (>=1.4.0)"] - -[[package]] -category = "main" -description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books" -name = "sphinxcontrib-applehelp" -optional = true -python-versions = ">=3.5" -version = "1.0.2" - -[package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] -test = ["pytest"] - -[[package]] -category = "main" -description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." -name = "sphinxcontrib-devhelp" -optional = true -python-versions = ">=3.5" -version = "1.0.2" - -[package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] -test = ["pytest"] - -[[package]] -category = "main" -description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" -name = "sphinxcontrib-htmlhelp" -optional = true -python-versions = ">=3.5" -version = "1.0.3" - -[package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] -test = ["pytest", "html5lib"] - -[[package]] -category = "main" -description = "A sphinx extension which renders display math in HTML via JavaScript" -name = "sphinxcontrib-jsmath" -optional = true -python-versions = ">=3.5" -version = "1.0.1" - -[package.extras] -test = ["pytest", "flake8", "mypy"] - -[[package]] -category = "main" -description = "Sphinx \"napoleon\" extension." -name = "sphinxcontrib-napoleon" -optional = true -python-versions = "*" -version = "0.7" - -[package.dependencies] -pockets = ">=0.3" -six = ">=1.5.2" - -[[package]] -category = "main" -description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." -name = "sphinxcontrib-qthelp" -optional = true -python-versions = ">=3.5" -version = "1.0.3" - -[package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] -test = ["pytest"] - -[[package]] -category = "main" -description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." -name = "sphinxcontrib-serializinghtml" -optional = true -python-versions = ">=3.5" -version = "1.1.4" - -[package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] -test = ["pytest"] - [[package]] category = "main" description = "TatSu takes a grammar in a variation of EBNF as input, and outputs a memoizing PEG/Packrat parser in Python." @@ -565,43 +78,6 @@ version = "4.4.0" [package.extras] future-regex = ["regex"] -[[package]] -category = "main" -description = "a fork of Python 2 and 3 ast modules with type comment support" -name = "typed-ast" -optional = true -python-versions = "*" -version = "1.4.1" - -[[package]] -category = "main" -description = "Backported and Experimental Type Hints for Python 3.5+" -name = "typing-extensions" -optional = true -python-versions = "*" -version = "3.7.4.2" - -[[package]] -category = "main" -description = "HTTP library with thread-safe connection pooling, file post, and more." -name = "urllib3" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" -version = "1.25.8" - -[package.extras] -brotli = ["brotlipy (>=0.6.0)"] -secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] -socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] - -[[package]] -category = "main" -description = "Measures number of Terminal column cells of wide-character codes" -name = "wcwidth" -optional = true -python-versions = "*" -version = "0.1.9" - [[package]] category = "main" description = "Backport of pathlib-compatible object wrapper for zip files" @@ -615,314 +91,35 @@ version = "3.1.0" docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] -[extras] -dev = ["pytest", "pytest-cov", "hypothesis", "mypy", "flake8", "sphinx", "sphinxcontrib-napoleon", "sphinx-autodoc-typehints"] - [metadata] -content-hash = "883117c6dbfa3234b077a54d07edc062bb5ffdd8bc65248d7352583ccdf6482e" +content-hash = "dfd72de68240eac76c80e0ceb594465acd068b5de51b98c92173040148bfea53" python-versions = "^3.6" [metadata.files] -alabaster = [ - {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"}, - {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, -] -atomicwrites = [ - {file = "atomicwrites-1.3.0-py2.py3-none-any.whl", hash = "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4"}, - {file = "atomicwrites-1.3.0.tar.gz", hash = "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"}, -] attrs = [ {file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"}, {file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"}, ] -babel = [ - {file = "Babel-2.8.0-py2.py3-none-any.whl", hash = "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4"}, - {file = "Babel-2.8.0.tar.gz", hash = "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38"}, -] -certifi = [ - {file = "certifi-2020.4.5.1-py2.py3-none-any.whl", hash = "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304"}, - {file = "certifi-2020.4.5.1.tar.gz", hash = "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519"}, -] -chardet = [ - {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, - {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, -] -colorama = [ - {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, - {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"}, -] -coverage = [ - {file = "coverage-5.0.4-cp27-cp27m-macosx_10_12_x86_64.whl", hash = "sha256:8a620767b8209f3446197c0e29ba895d75a1e272a36af0786ec70fe7834e4307"}, - {file = "coverage-5.0.4-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:73aa6e86034dad9f00f4bbf5a666a889d17d79db73bc5af04abd6c20a014d9c8"}, - {file = "coverage-5.0.4-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:408ce64078398b2ee2ec08199ea3fcf382828d2f8a19c5a5ba2946fe5ddc6c31"}, - {file = "coverage-5.0.4-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:cda33311cb9fb9323958a69499a667bd728a39a7aa4718d7622597a44c4f1441"}, - {file = "coverage-5.0.4-cp27-cp27m-win32.whl", hash = "sha256:5f587dfd83cb669933186661a351ad6fc7166273bc3e3a1531ec5c783d997aac"}, - {file = "coverage-5.0.4-cp27-cp27m-win_amd64.whl", hash = "sha256:9fad78c13e71546a76c2f8789623eec8e499f8d2d799f4b4547162ce0a4df435"}, - {file = "coverage-5.0.4-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:2e08c32cbede4a29e2a701822291ae2bc9b5220a971bba9d1e7615312efd3037"}, - {file = "coverage-5.0.4-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:922fb9ef2c67c3ab20e22948dcfd783397e4c043a5c5fa5ff5e9df5529074b0a"}, - {file = "coverage-5.0.4-cp35-cp35m-macosx_10_12_x86_64.whl", hash = "sha256:c3fc325ce4cbf902d05a80daa47b645d07e796a80682c1c5800d6ac5045193e5"}, - {file = "coverage-5.0.4-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:046a1a742e66d065d16fb564a26c2a15867f17695e7f3d358d7b1ad8a61bca30"}, - {file = "coverage-5.0.4-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:6ad6ca45e9e92c05295f638e78cd42bfaaf8ee07878c9ed73e93190b26c125f7"}, - {file = "coverage-5.0.4-cp35-cp35m-win32.whl", hash = "sha256:eda55e6e9ea258f5e4add23bcf33dc53b2c319e70806e180aecbff8d90ea24de"}, - {file = "coverage-5.0.4-cp35-cp35m-win_amd64.whl", hash = "sha256:4a8a259bf990044351baf69d3b23e575699dd60b18460c71e81dc565f5819ac1"}, - {file = "coverage-5.0.4-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:f372cdbb240e09ee855735b9d85e7f50730dcfb6296b74b95a3e5dea0615c4c1"}, - {file = "coverage-5.0.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a37c6233b28e5bc340054cf6170e7090a4e85069513320275a4dc929144dccf0"}, - {file = "coverage-5.0.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:443be7602c790960b9514567917af538cac7807a7c0c0727c4d2bbd4014920fd"}, - {file = "coverage-5.0.4-cp36-cp36m-win32.whl", hash = "sha256:165a48268bfb5a77e2d9dbb80de7ea917332a79c7adb747bd005b3a07ff8caf0"}, - {file = "coverage-5.0.4-cp36-cp36m-win_amd64.whl", hash = "sha256:0a907199566269e1cfa304325cc3b45c72ae341fbb3253ddde19fa820ded7a8b"}, - {file = "coverage-5.0.4-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:513e6526e0082c59a984448f4104c9bf346c2da9961779ede1fc458e8e8a1f78"}, - {file = "coverage-5.0.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:3844c3dab800ca8536f75ae89f3cf566848a3eb2af4d9f7b1103b4f4f7a5dad6"}, - {file = "coverage-5.0.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:641e329e7f2c01531c45c687efcec8aeca2a78a4ff26d49184dce3d53fc35014"}, - {file = "coverage-5.0.4-cp37-cp37m-win32.whl", hash = "sha256:db1d4e38c9b15be1521722e946ee24f6db95b189d1447fa9ff18dd16ba89f732"}, - {file = "coverage-5.0.4-cp37-cp37m-win_amd64.whl", hash = "sha256:62061e87071497951155cbccee487980524d7abea647a1b2a6eb6b9647df9006"}, - {file = "coverage-5.0.4-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:65a7e00c00472cd0f59ae09d2fb8a8aaae7f4a0cf54b2b74f3138d9f9ceb9cb2"}, - {file = "coverage-5.0.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1f66cf263ec77af5b8fe14ef14c5e46e2eb4a795ac495ad7c03adc72ae43fafe"}, - {file = "coverage-5.0.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:85596aa5d9aac1bf39fe39d9fa1051b0f00823982a1de5766e35d495b4a36ca9"}, - {file = "coverage-5.0.4-cp38-cp38-win32.whl", hash = "sha256:86a0ea78fd851b313b2e712266f663e13b6bc78c2fb260b079e8b67d970474b1"}, - {file = "coverage-5.0.4-cp38-cp38-win_amd64.whl", hash = "sha256:03f630aba2b9b0d69871c2e8d23a69b7fe94a1e2f5f10df5049c0df99db639a0"}, - {file = "coverage-5.0.4-cp39-cp39-win32.whl", hash = "sha256:7c9762f80a25d8d0e4ab3cb1af5d9dffbddb3ee5d21c43e3474c84bf5ff941f7"}, - {file = "coverage-5.0.4-cp39-cp39-win_amd64.whl", hash = "sha256:4482f69e0701139d0f2c44f3c395d1d1d37abd81bfafbf9b6efbe2542679d892"}, - {file = "coverage-5.0.4.tar.gz", hash = "sha256:1b60a95fc995649464e0cd48cecc8288bac5f4198f21d04b8229dc4097d76823"}, -] -docutils = [ - {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"}, - {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, -] -entrypoints = [ - {file = "entrypoints-0.3-py2.py3-none-any.whl", hash = "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19"}, - {file = "entrypoints-0.3.tar.gz", hash = "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"}, -] -flake8 = [ - {file = "flake8-3.7.9-py2.py3-none-any.whl", hash = "sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca"}, - {file = "flake8-3.7.9.tar.gz", hash = "sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb"}, -] -hypothesis = [ - {file = "hypothesis-5.8.0-py3-none-any.whl", hash = "sha256:84671369a278088f1d48f7ed2aca7975550344fa744783fe6cb84ad5f3f55ff2"}, - {file = "hypothesis-5.8.0.tar.gz", hash = "sha256:6023d9112ac23502abcb20ca3f336096fe97abab86e589cd9bf9b4bfcaa335d7"}, -] -idna = [ - {file = "idna-2.9-py2.py3-none-any.whl", hash = "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"}, - {file = "idna-2.9.tar.gz", hash = "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"}, -] -imagesize = [ - {file = "imagesize-1.2.0-py2.py3-none-any.whl", hash = "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1"}, - {file = "imagesize-1.2.0.tar.gz", hash = "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"}, -] importlib-metadata = [ {file = "importlib_metadata-1.6.0-py2.py3-none-any.whl", hash = "sha256:2a688cbaa90e0cc587f1df48bdc97a6eadccdcd9c35fb3f976a09e3b5016d90f"}, {file = "importlib_metadata-1.6.0.tar.gz", hash = "sha256:34513a8a0c4962bc66d35b359558fd8a5e10cd472d37aec5f66858addef32c1e"}, ] importlib-resources = [ - {file = "importlib_resources-1.4.0-py2.py3-none-any.whl", hash = "sha256:dd98ceeef3f5ad2ef4cc287b8586da4ebad15877f351e9688987ad663a0a29b8"}, - {file = "importlib_resources-1.4.0.tar.gz", hash = "sha256:4019b6a9082d8ada9def02bece4a76b131518866790d58fdda0b5f8c603b36c2"}, -] -jinja2 = [ - {file = "Jinja2-2.11.1-py2.py3-none-any.whl", hash = "sha256:b0eaf100007721b5c16c1fc1eecb87409464edc10469ddc9a22a27a99123be49"}, - {file = "Jinja2-2.11.1.tar.gz", hash = "sha256:93187ffbc7808079673ef52771baa950426fd664d3aad1d0fa3e95644360e250"}, -] -markupsafe = [ - {file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-win32.whl", hash = "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-win_amd64.whl", hash = "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e"}, - {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f"}, - {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-win32.whl", hash = "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-win_amd64.whl", hash = "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-win32.whl", hash = "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, - {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, -] -mccabe = [ - {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, - {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, -] -more-itertools = [ - {file = "more-itertools-8.2.0.tar.gz", hash = "sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507"}, - {file = "more_itertools-8.2.0-py3-none-any.whl", hash = "sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c"}, -] -mypy = [ - {file = "mypy-0.770-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:a34b577cdf6313bf24755f7a0e3f3c326d5c1f4fe7422d1d06498eb25ad0c600"}, - {file = "mypy-0.770-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:86c857510a9b7c3104cf4cde1568f4921762c8f9842e987bc03ed4f160925754"}, - {file = "mypy-0.770-cp35-cp35m-win_amd64.whl", hash = "sha256:a8ffcd53cb5dfc131850851cc09f1c44689c2812d0beb954d8138d4f5fc17f65"}, - {file = "mypy-0.770-cp36-cp36m-macosx_10_6_x86_64.whl", hash = "sha256:7687f6455ec3ed7649d1ae574136835a4272b65b3ddcf01ab8704ac65616c5ce"}, - {file = "mypy-0.770-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:3beff56b453b6ef94ecb2996bea101a08f1f8a9771d3cbf4988a61e4d9973761"}, - {file = "mypy-0.770-cp36-cp36m-win_amd64.whl", hash = "sha256:15b948e1302682e3682f11f50208b726a246ab4e6c1b39f9264a8796bb416aa2"}, - {file = "mypy-0.770-cp37-cp37m-macosx_10_6_x86_64.whl", hash = "sha256:b90928f2d9eb2f33162405f32dde9f6dcead63a0971ca8a1b50eb4ca3e35ceb8"}, - {file = "mypy-0.770-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:c56ffe22faa2e51054c5f7a3bc70a370939c2ed4de308c690e7949230c995913"}, - {file = "mypy-0.770-cp37-cp37m-win_amd64.whl", hash = "sha256:8dfb69fbf9f3aeed18afffb15e319ca7f8da9642336348ddd6cab2713ddcf8f9"}, - {file = "mypy-0.770-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:219a3116ecd015f8dca7b5d2c366c973509dfb9a8fc97ef044a36e3da66144a1"}, - {file = "mypy-0.770-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7ec45a70d40ede1ec7ad7f95b3c94c9cf4c186a32f6bacb1795b60abd2f9ef27"}, - {file = "mypy-0.770-cp38-cp38-win_amd64.whl", hash = "sha256:f91c7ae919bbc3f96cd5e5b2e786b2b108343d1d7972ea130f7de27fdd547cf3"}, - {file = "mypy-0.770-py3-none-any.whl", hash = "sha256:3b1fc683fb204c6b4403a1ef23f0b1fac8e4477091585e0c8c54cbdf7d7bb164"}, - {file = "mypy-0.770.tar.gz", hash = "sha256:8a627507ef9b307b46a1fea9513d5c98680ba09591253082b4c48697ba05a4ae"}, -] -mypy-extensions = [ - {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, - {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, -] -packaging = [ - {file = "packaging-20.3-py2.py3-none-any.whl", hash = "sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752"}, - {file = "packaging-20.3.tar.gz", hash = "sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3"}, -] -pluggy = [ - {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, - {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, -] -pockets = [ - {file = "pockets-0.9.1-py2.py3-none-any.whl", hash = "sha256:68597934193c08a08eb2bf6a1d85593f627c22f9b065cc727a4f03f669d96d86"}, - {file = "pockets-0.9.1.tar.gz", hash = "sha256:9320f1a3c6f7a9133fe3b571f283bcf3353cd70249025ae8d618e40e9f7e92b3"}, -] -py = [ - {file = "py-1.8.1-py2.py3-none-any.whl", hash = "sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0"}, - {file = "py-1.8.1.tar.gz", hash = "sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa"}, -] -pycodestyle = [ - {file = "pycodestyle-2.5.0-py2.py3-none-any.whl", hash = "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56"}, - {file = "pycodestyle-2.5.0.tar.gz", hash = "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"}, -] -pyflakes = [ - {file = "pyflakes-2.1.1-py2.py3-none-any.whl", hash = "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0"}, - {file = "pyflakes-2.1.1.tar.gz", hash = "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"}, -] -pygments = [ - {file = "Pygments-2.6.1-py3-none-any.whl", hash = "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324"}, - {file = "Pygments-2.6.1.tar.gz", hash = "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44"}, -] -pyparsing = [ - {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, - {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, -] -pytest = [ - {file = "pytest-5.4.1-py3-none-any.whl", hash = "sha256:0e5b30f5cb04e887b91b1ee519fa3d89049595f428c1db76e73bd7f17b09b172"}, - {file = "pytest-5.4.1.tar.gz", hash = "sha256:84dde37075b8805f3d1f392cc47e38a0e59518fb46a431cfdaf7cf1ce805f970"}, -] -pytest-cov = [ - {file = "pytest-cov-2.8.1.tar.gz", hash = "sha256:cc6742d8bac45070217169f5f72ceee1e0e55b0221f54bcf24845972d3a47f2b"}, - {file = "pytest_cov-2.8.1-py2.py3-none-any.whl", hash = "sha256:cdbdef4f870408ebdbfeb44e63e07eb18bb4619fae852f6e760645fa36172626"}, + {file = "importlib_resources-1.5.0-py2.py3-none-any.whl", hash = "sha256:85dc0b9b325ff78c8bef2e4ff42616094e16b98ebd5e3b50fe7e2f0bbcdcde49"}, + {file = "importlib_resources-1.5.0.tar.gz", hash = "sha256:6f87df66833e1942667108628ec48900e02a4ab4ad850e25fbf07cb17cf734ca"}, ] python-dateutil = [ {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, ] -pytz = [ - {file = "pytz-2019.3-py2.py3-none-any.whl", hash = "sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d"}, - {file = "pytz-2019.3.tar.gz", hash = "sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be"}, -] -requests = [ - {file = "requests-2.23.0-py2.py3-none-any.whl", hash = "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee"}, - {file = "requests-2.23.0.tar.gz", hash = "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"}, -] six = [ {file = "six-1.14.0-py2.py3-none-any.whl", hash = "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"}, {file = "six-1.14.0.tar.gz", hash = "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a"}, ] -snowballstemmer = [ - {file = "snowballstemmer-2.0.0-py2.py3-none-any.whl", hash = "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0"}, - {file = "snowballstemmer-2.0.0.tar.gz", hash = "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52"}, -] -sortedcontainers = [ - {file = "sortedcontainers-2.1.0-py2.py3-none-any.whl", hash = "sha256:d9e96492dd51fae31e60837736b38fe42a187b5404c16606ff7ee7cd582d4c60"}, - {file = "sortedcontainers-2.1.0.tar.gz", hash = "sha256:974e9a32f56b17c1bac2aebd9dcf197f3eb9cd30553c5852a3187ad162e1a03a"}, -] -sphinx = [ - {file = "Sphinx-3.0.1-py3-none-any.whl", hash = "sha256:8411878f4768ec2a8896b844d68070204f9354a831b37937989c2e559d29dffc"}, - {file = "Sphinx-3.0.1.tar.gz", hash = "sha256:50972d83b78990fd61d0d3fe8620814cae53db29443e92c13661bc43dff46ec8"}, -] -sphinx-autodoc-typehints = [ - {file = "sphinx-autodoc-typehints-1.10.3.tar.gz", hash = "sha256:a6b3180167479aca2c4d1ed3b5cb044a70a76cccd6b38662d39288ebd9f0dff0"}, - {file = "sphinx_autodoc_typehints-1.10.3-py3-none-any.whl", hash = "sha256:27c9e6ef4f4451766ab8d08b2d8520933b97beb21c913f3df9ab2e59b56e6c6c"}, -] -sphinxcontrib-applehelp = [ - {file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"}, - {file = "sphinxcontrib_applehelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a"}, -] -sphinxcontrib-devhelp = [ - {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, - {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, -] -sphinxcontrib-htmlhelp = [ - {file = "sphinxcontrib-htmlhelp-1.0.3.tar.gz", hash = "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b"}, - {file = "sphinxcontrib_htmlhelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f"}, -] -sphinxcontrib-jsmath = [ - {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, - {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, -] -sphinxcontrib-napoleon = [ - {file = "sphinxcontrib-napoleon-0.7.tar.gz", hash = "sha256:407382beed396e9f2d7f3043fad6afda95719204a1e1a231ac865f40abcbfcf8"}, - {file = "sphinxcontrib_napoleon-0.7-py2.py3-none-any.whl", hash = "sha256:711e41a3974bdf110a484aec4c1a556799eb0b3f3b897521a018ad7e2db13fef"}, -] -sphinxcontrib-qthelp = [ - {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, - {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, -] -sphinxcontrib-serializinghtml = [ - {file = "sphinxcontrib-serializinghtml-1.1.4.tar.gz", hash = "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc"}, - {file = "sphinxcontrib_serializinghtml-1.1.4-py2.py3-none-any.whl", hash = "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a"}, -] tatsu = [ {file = "TatSu-4.4.0-py2.py3-none-any.whl", hash = "sha256:c9211eeee9a2d4c90f69879ec0b518b1aa0d9450249cb0dd181f5f5b18be0a92"}, {file = "TatSu-4.4.0.zip", hash = "sha256:80713413473a009f2081148d0f494884cabaf9d6866b71f2a68a92b6442f343d"}, ] -typed-ast = [ - {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, - {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"}, - {file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"}, - {file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"}, - {file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"}, - {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"}, - {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"}, - {file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"}, - {file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"}, - {file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"}, - {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"}, - {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"}, - {file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"}, - {file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"}, - {file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"}, - {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"}, - {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"}, - {file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"}, - {file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"}, - {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"}, - {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, -] -typing-extensions = [ - {file = "typing_extensions-3.7.4.2-py2-none-any.whl", hash = "sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392"}, - {file = "typing_extensions-3.7.4.2-py3-none-any.whl", hash = "sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5"}, - {file = "typing_extensions-3.7.4.2.tar.gz", hash = "sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae"}, -] -urllib3 = [ - {file = "urllib3-1.25.8-py2.py3-none-any.whl", hash = "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc"}, - {file = "urllib3-1.25.8.tar.gz", hash = "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"}, -] -wcwidth = [ - {file = "wcwidth-0.1.9-py2.py3-none-any.whl", hash = "sha256:cafe2186b3c009a04067022ce1dcd79cb38d8d65ee4f4791b8888d6599d1bbe1"}, - {file = "wcwidth-0.1.9.tar.gz", hash = "sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1"}, -] zipp = [ {file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"}, {file = "zipp-3.1.0.tar.gz", hash = "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"}, diff --git a/pyproject.toml b/pyproject.toml index 17d3c9df..1c2cf969 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,21 +32,6 @@ attrs = ">=19.2" tatsu = ">4.2" importlib_resources = "^1.4" -# Tools used by tox for testing and building docs -# see https://github.com/python-poetry/poetry/issues/1941#issuecomment-581602064 -# [tool.poetry.dev-dependencies] -pytest = { version = "^5.2", optional = true } -pytest-cov = { version = "^2.8.1", optional = true } -hypothesis = { version = "^5.8.0", optional = true } -mypy = { version = ">=0.770", optional = true } -flake8 = { version = "^3.7.9", optional = true } -sphinx = { version = "^3.0.0", optional = true } -sphinxcontrib-napoleon = { version = "^0.7", optional = true } -sphinx-autodoc-typehints = { version = "^1.10.3", optional = true } - -[tool.poetry.extras] -dev = ["pytest", "pytest-cov", "hypothesis", "mypy", "flake8", "sphinx", "sphinxcontrib-napoleon", "sphinx-autodoc-typehints"] - [build-system] requires = ["poetry>=0.12"] build-backend = "poetry.masonry.api" diff --git a/src/ics/converter/base.py b/src/ics/converter/base.py index e405c57e..ac2728af 100644 --- a/src/ics/converter/base.py +++ b/src/ics/converter/base.py @@ -1,5 +1,6 @@ import abc import warnings +from types import SimpleNamespace from typing import Any, ClassVar, Dict, List, MutableSequence, Optional, TYPE_CHECKING, Tuple, Type, Union, cast import attr @@ -59,18 +60,18 @@ class AttributeConverter(GenericConverter, abc.ABC): is_required: bool def __attrs_post_init__(self): - multi_value_type, value_type, value_types = extract_attr_type(self.attribute) - _priority = self.attribute.metadata.get("ics_priority", self.default_priority) - is_required = self.attribute.metadata.get("ics_required", None) - if is_required is None: + v = SimpleNamespace() + v.multi_value_type, v.value_type, v.value_types = extract_attr_type(self.attribute) + v._priority = self.attribute.metadata.get("ics_priority", self.default_priority) + v.is_required = self.attribute.metadata.get("ics_required", None) + if v.is_required is None: if not self.attribute.init: - is_required = False + v.is_required = False elif self.attribute.default is not attr.NOTHING: - is_required = False + v.is_required = False else: - is_required = True - for key, value in locals().items(): # all variables created in __attrs_post_init__ will be set on self - if key == "self" or key.startswith("__"): continue + v.is_required = True + for key, value in v.__dict__.items(): # all variables created in __attrs_post_init__.v will be set on self object.__setattr__(self, key, value) def _check_component(self, component: "Component", context: ContextDict): diff --git a/tox.ini b/tox.ini index 8ac8d77e..73720f46 100644 --- a/tox.ini +++ b/tox.ini @@ -1,11 +1,15 @@ [tox] isolated_build = true -envlist = py36, py37, py38, checks, docs +envlist = py36, py37, py38, pypy3, checks, docs [testenv] description = Run the pytest suite -extras = - dev +setenv = + PYTHONDEVMODE=1 +deps = + pytest + pytest-cov + hypothesis commands = pytest -V python -c 'import sys, pkg_resources; dist = pkg_resources.get_distribution("ics"); print(repr(dist), dist.__dict__, sys.path)' @@ -13,30 +17,33 @@ commands = [testenv:flake8] description = Run the flake8 code style checks -basepython = python3.8 +deps = flake8 commands = flake8 --version flake8 src/ [testenv:mypy] description = Run the mypy type checks -basepython = python3.8 +deps = mypy commands = mypy -V mypy --config-file=tox.ini src/ [testenv:checks] description = Run all code checkers (flake8 and mypy) -basepython = python3 -# deps are specified on 'testenv' for all commands -# deps = {[testenv:flake8]deps} {[testenv:mypy]deps} +deps = + {[testenv:flake8]deps} + {[testenv:mypy]deps} commands = {[testenv:flake8]commands} {[testenv:mypy]commands} [testenv:docs] description = Build the documentation with sphinx -basepython = python3.8 +deps = + sphinx + sphinxcontrib-napoleon + sphinx-autodoc-typehints commands = sphinx-build -d "{toxworkdir}/docs_doctree" doc "{toxworkdir}/docs_out" --color -bhtml {posargs} python -c 'import pathlib; print("documentation available under file://\{0\}".format(pathlib.Path(r"{toxworkdir}") / "docs_out" / "index.html"))' From 28848f89981b02fac24220bb15b19f62d302ccea Mon Sep 17 00:00:00 2001 From: Simon Dominik Niko Fink Date: Sun, 10 May 2020 22:22:27 +0200 Subject: [PATCH 41/43] update developing documentation --- CONTRIBUTING.rst | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 7377e0d2..8f266f3d 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -43,9 +43,6 @@ There are three Python tools required to develop, test, and release ics.py: * `bumpversion `_ to help with making a release. Their respective configuration files are :file:`pyproject.toml`, :file:`tox.ini` and :file:`.bumpversion.cfg`. -The ``poetry.lock`` file optionally locks the dependency versions against which we want to develop, -which is independent from the versions the library pulls in when installed as a dependency itself (where we are pretty liberal), -and the versions we test against (which is always the latest releases installed by tox). Install the tools via pip: .. code-block:: bash @@ -73,6 +70,11 @@ Now you are ready to setup your development environment using the following comm This will create a new virtualenv and install the dependencies for using ics.py. Furthermore, the current source of the ics.py package will be available similar to running ``./setup.py develop``. To access the virtualenv, use ``poetry run python`` or ``poetry shell``. +The :file:`poetry.lock` file locks the versions of dependencies in the development environment set up by poetry, so that this environment is the same for everyone. +The file is only read by poetry and not included in any distributions, so these restrictions don't apply when running ``pip install ics``. +As tox manages its own environments and also doesn't read the lock file, it just installs the latest versions of dependencies for testing. +More details on the poetry side can be found `here `_. + If you made some changes and now want to lint your code, run the testsuite, or build the documentation, run tox. You don't have to worry about which versions in which venvs are installed and whether you're directly testing against the sources or against a built package, tox handles all that for you: @@ -89,15 +91,16 @@ To run a single task and not the whole testsuite, use the ``-e`` flag: To get a list of all available tasks, run :command:`tox -av`. .. note:: - If you want to run any tasks of tox manually, you need to make sure that you also have all the dependencies of the task installed. - This is easily ensured by also installing the "dev" extra dependencies into you main environment: + If you want to run any tasks of tox manually, you need to make sure that you also have all the testing dependencies of the task listed in ``tox.ini`` installed. + You can also let tox `set up `_ your development environment or re-use one of its test environments: .. code-block:: bash - $ poetry install --extras "dev" - $ poetry shell - (.venv) $ pytest - (.venv) $ cd doc && sphinx-build + $ tox -e py38 + $ source .tox/py38/bin/activate + (py38) $ pytest + + This also works without having poetry installed. If you are fixing a bug ^^^^^^^^^^^^^^^^^^^^^^^ From 5519b7a2169f789a0f930200db9c6e808c0a0027 Mon Sep 17 00:00:00 2001 From: Simon Dominik Niko Fink Date: Mon, 11 May 2020 19:51:17 +0200 Subject: [PATCH 42/43] fix non-ASCII whitespace handling --- src/ics/grammar/__init__.py | 11 +++++++---- tests/grammar/__init__.py | 3 ++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/ics/grammar/__init__.py b/src/ics/grammar/__init__.py index d081cfe0..d8bc873d 100644 --- a/src/ics/grammar/__init__.py +++ b/src/ics/grammar/__init__.py @@ -81,7 +81,7 @@ def parse(cls, line): if "\n" in line or "\r" in line: raise ValueError("ContentLine can only contain escaped newlines") try: - ast = GRAMMAR.parse(line) + ast = GRAMMAR.parse(line, whitespace="") except FailedToken: raise ParseError() else: @@ -262,10 +262,11 @@ def unfold_lines(physical_lines): line = line.rstrip('\r') if not current_line: current_line = line - elif line[0] in (' ', '\t'): + elif line and line[0] in (' ', '\t'): current_line += line[1:] else: - yield current_line + if len(current_line) > 0: + yield current_line current_line = line if current_line: yield current_line @@ -293,4 +294,6 @@ def lines_to_container(lines): def string_to_container(txt): - return lines_to_container(txt.splitlines()) + # unicode newlines are interpreted as such by str.splitlines(), but not by the ics standard + # "A:abc\x85def".splitlines() => ['A:abc', 'def'] which is wrong + return lines_to_container(re.split("\r?\n|\r", txt)) diff --git a/tests/grammar/__init__.py b/tests/grammar/__init__.py index 36ff73eb..b81c0d0c 100644 --- a/tests/grammar/__init__.py +++ b/tests/grammar/__init__.py @@ -1,7 +1,7 @@ import re import pytest -from hypothesis import assume, given +from hypothesis import assume, given, example from hypothesis.strategies import characters, text from ics.grammar import Container, ContentLine, ParseError, QuotedParamValue, escape_param, string_to_container, unfold_lines @@ -111,6 +111,7 @@ def test_trailing_escape_param(): @given(name=NAME, value=VALUE) +@example(name='A', value='abc\x85abc') def test_any_name_value_recode(name, value): raw = "%s:%s" % (name, value) assert ContentLine.parse(raw).serialize() == raw From fdb9aa2b627628faa5eb199fd90eab48b22e4185 Mon Sep 17 00:00:00 2001 From: Simon Dominik Niko Fink Date: Sat, 16 May 2020 11:49:32 +0200 Subject: [PATCH 43/43] update test/dev dependencies --- CONTRIBUTING.rst | 41 ++--- poetry.lock | 367 +++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 13 ++ src/ics/timespan.py | 13 +- tox.ini | 11 +- 5 files changed, 412 insertions(+), 33 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 8f266f3d..37c79bae 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -16,8 +16,8 @@ How to submit an issue Please include the following in your bug reports: -* the version of ics.py you are using; run ``pip freeze | grep ics`` -* the version of Python ``python -v`` +* the version of ics.py you are using; run :command:`pip freeze | grep ics` +* the version of Python :command:`python -v` * the OS you are using Please also include a (preferably minimal) example of the code or @@ -40,22 +40,22 @@ There are three Python tools required to develop, test, and release ics.py: * `poetry `_ for managing virtualenvs, dependencies, building, and publishing the package. * `tox `_ for running the testsuite and building the documentation. -* `bumpversion `_ to help with making a release. +* `bump2version `_ to help with making a release. Their respective configuration files are :file:`pyproject.toml`, :file:`tox.ini` and :file:`.bumpversion.cfg`. Install the tools via pip: .. code-block:: bash - $ pip install tox poetry bumpversion --user + $ pip install tox poetry bump2version --user .. note:: If you want to develop using multiple different Python versions, you might want to consider the `poetry installer `_. Poetry will automatically manage a virtualenv that you can use for developing. - By default, it will be located centrally in your home directory (e.g. in ``/home/user/.cache/pypoetry/virtualenvs/``). - To make poetry use a ``./.venv/`` directory within the ics.py folder, use the following config: + By default, it will be located centrally in your home directory (e.g. in :file:`/home/user/.cache/pypoetry/virtualenvs/`). + To make poetry use a :file:`./.venv/` directory within the ics.py folder, use the following config: .. code-block:: bash @@ -68,12 +68,12 @@ Now you are ready to setup your development environment using the following comm $ poetry install This will create a new virtualenv and install the dependencies for using ics.py. -Furthermore, the current source of the ics.py package will be available similar to running ``./setup.py develop``. -To access the virtualenv, use ``poetry run python`` or ``poetry shell``. -The :file:`poetry.lock` file locks the versions of dependencies in the development environment set up by poetry, so that this environment is the same for everyone. -The file is only read by poetry and not included in any distributions, so these restrictions don't apply when running ``pip install ics``. -As tox manages its own environments and also doesn't read the lock file, it just installs the latest versions of dependencies for testing. -More details on the poetry side can be found `here `_. +Furthermore, the current source of the ics.py package will be available similar to running :command:`./setup.py develop`. +To access the virtualenv, use :command:`poetry run python` or :command:`poetry shell`. +The :file:`poetry.lock` file locks the versions of dependencies in the development environment set up by poetry. This ensures that such an environment is the same for everyone. +The file :file:`poetry.lock` is only read by poetry and not included in any distributions. These restrictions don't apply when running :command:`pip install ics`. +As tox manages its own environments and doesn't read the lock file, it installs the latest versions of dependencies for testing. +More details on the poetry side can be found in the `poetry documentation `_. If you made some changes and now want to lint your code, run the testsuite, or build the documentation, run tox. You don't have to worry about which versions in which venvs are installed and whether you're directly testing against the sources or against a built package, tox handles all that for you: @@ -91,8 +91,9 @@ To run a single task and not the whole testsuite, use the ``-e`` flag: To get a list of all available tasks, run :command:`tox -av`. .. note:: - If you want to run any tasks of tox manually, you need to make sure that you also have all the testing dependencies of the task listed in ``tox.ini`` installed. - You can also let tox `set up `_ your development environment or re-use one of its test environments: + If you want to run any tasks of tox manually, make sure you have all the dependencies of the task listed in :file:`tox.ini`. + For testing with pytest, this can be done through poetry by installing the ``test`` extra: :command:`poetry install -E test`. + Alternatively, you can also let tox `set up `_ your development environment or re-use one of its test environments: .. code-block:: bash @@ -124,8 +125,8 @@ We will ask you to provide: Last thing ^^^^^^^^^^ -* Please add yourself to ``AUTHORS.rst`` -* and state your changes in ``CHANGELOG.rst``. +* Please add yourself to :file:`AUTHORS.rst` +* and state your changes in :file:`CHANGELOG.rst`. .. note:: Your PR will most likely be squashed in a single commit, authored @@ -145,14 +146,14 @@ If you want to publish a new release, use the following steps # Grab the sources and install the dev tools git clone https://github.com/C4ptainCrunch/ics.py.git && cd ics.py - pip install tox poetry bumpversion --user + pip install tox poetry bump2version --user # Make sure all the test run tox && echo "Ready to make a new release" \ || echo "Please fix all the tests first" # Bump the version and make a "0.8.0-dev -> 0.8.0 (release)" commit - bumpversion --verbose release + bump2version --verbose release # Build the package poetry build # Ensure that the version numbers are consistent @@ -165,13 +166,13 @@ If you want to publish a new release, use the following steps poetry publish # Bump the version again to start development of next version - bumpversion --verbose minor # 0.8.0 (release) -> 0.9.0-dev + bump2version --verbose minor # 0.8.0 (release) -> 0.9.0-dev # Start new changelog vi CHANGELOG.rst && git commit -i CHANGELOG.rst --amend # Publish to GitHub git push && git push --tags -Please note that bumpversion directly makes a commit with the new version if you don't +Please note that bump2version directly makes a commit with the new version if you don't pass ``--no-commit`` or ``--dry-run``, but that's no problem as you can easily amend any changes you want to make. Further things to check: diff --git a/poetry.lock b/poetry.lock index 602f7c3b..4cb42efd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,3 +1,20 @@ +[[package]] +category = "main" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +name = "appdirs" +optional = true +python-versions = "*" +version = "1.4.4" + +[[package]] +category = "main" +description = "Atomic file writes." +marker = "sys_platform == \"win32\"" +name = "atomicwrites" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.4.0" + [[package]] category = "main" description = "Classes Without Boilerplate" @@ -12,6 +29,73 @@ dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.int docs = ["sphinx", "zope.interface"] tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] +[[package]] +category = "main" +description = "Version-bump your software with a single command!" +name = "bump2version" +optional = true +python-versions = ">=3.5" +version = "1.0.0" + +[[package]] +category = "main" +description = "Cross-platform colored terminal text." +marker = "sys_platform == \"win32\" or platform_system == \"Windows\"" +name = "colorama" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "0.4.3" + +[[package]] +category = "main" +description = "Code coverage measurement for Python" +name = "coverage" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" +version = "5.1" + +[package.extras] +toml = ["toml"] + +[[package]] +category = "main" +description = "Distribution utilities" +name = "distlib" +optional = true +python-versions = "*" +version = "0.3.0" + +[[package]] +category = "main" +description = "A platform independent file lock." +name = "filelock" +optional = true +python-versions = "*" +version = "3.0.12" + +[[package]] +category = "main" +description = "A library for property-based testing" +name = "hypothesis" +optional = true +python-versions = ">=3.5.2" +version = "5.14.0" + +[package.dependencies] +attrs = ">=19.2.0" +sortedcontainers = ">=2.1.0,<3.0.0" + +[package.extras] +all = ["django (>=2.2)", "dpcontracts (>=0.4)", "lark-parser (>=0.6.5)", "numpy (>=1.9.0)", "pandas (>=0.19)", "pytest (>=4.3)", "python-dateutil (>=1.4)", "pytz (>=2014.1)"] +dateutil = ["python-dateutil (>=1.4)"] +django = ["pytz (>=2014.1)", "django (>=2.2)"] +dpcontracts = ["dpcontracts (>=0.4)"] +lark = ["lark-parser (>=0.6.5)"] +numpy = ["numpy (>=1.9.0)"] +pandas = ["pandas (>=0.19)"] +pytest = ["pytest (>=4.3)"] +pytz = ["pytz (>=2014.1)"] + [[package]] category = "main" description = "Read metadata from Python packages" @@ -48,6 +132,99 @@ version = ">=0.4" [package.extras] docs = ["sphinx", "rst.linker", "jaraco.packaging"] +[[package]] +category = "main" +description = "More routines for operating on iterables, beyond itertools" +name = "more-itertools" +optional = true +python-versions = ">=3.5" +version = "8.3.0" + +[[package]] +category = "main" +description = "Core utilities for Python packages" +name = "packaging" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "20.3" + +[package.dependencies] +pyparsing = ">=2.0.2" +six = "*" + +[[package]] +category = "main" +description = "plugin and hook calling mechanisms for python" +name = "pluggy" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "0.13.1" + +[package.dependencies] +[package.dependencies.importlib-metadata] +python = "<3.8" +version = ">=0.12" + +[package.extras] +dev = ["pre-commit", "tox"] + +[[package]] +category = "main" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +name = "py" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.8.1" + +[[package]] +category = "main" +description = "Python parsing module" +name = "pyparsing" +optional = true +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +version = "2.4.7" + +[[package]] +category = "main" +description = "pytest: simple powerful testing with Python" +name = "pytest" +optional = true +python-versions = ">=3.5" +version = "5.4.2" + +[package.dependencies] +atomicwrites = ">=1.0" +attrs = ">=17.4.0" +colorama = "*" +more-itertools = ">=4.0.0" +packaging = "*" +pluggy = ">=0.12,<1.0" +py = ">=1.5.0" +wcwidth = "*" + +[package.dependencies.importlib-metadata] +python = "<3.8" +version = ">=0.12" + +[package.extras] +checkqa-mypy = ["mypy (v0.761)"] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] + +[[package]] +category = "main" +description = "Pytest plugin for measuring coverage." +name = "pytest-cov" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.8.1" + +[package.dependencies] +coverage = ">=4.4" +pytest = ">=3.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests (2.0.2)", "six", "virtualenv"] + [[package]] category = "main" description = "Extensions to the standard Python datetime module" @@ -67,6 +244,14 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" version = "1.14.0" +[[package]] +category = "main" +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" +name = "sortedcontainers" +optional = true +python-versions = "*" +version = "2.1.0" + [[package]] category = "main" description = "TatSu takes a grammar in a variation of EBNF as input, and outputs a memoizing PEG/Packrat parser in Python." @@ -78,6 +263,74 @@ version = "4.4.0" [package.extras] future-regex = ["regex"] +[[package]] +category = "main" +description = "Python Library for Tom's Obvious, Minimal Language" +name = "toml" +optional = true +python-versions = "*" +version = "0.10.1" + +[[package]] +category = "main" +description = "tox is a generic virtualenv management and test command line tool" +name = "tox" +optional = true +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +version = "3.15.0" + +[package.dependencies] +colorama = ">=0.4.1" +filelock = ">=3.0.0,<4" +packaging = ">=14" +pluggy = ">=0.12.0,<1" +py = ">=1.4.17,<2" +six = ">=1.14.0,<2" +toml = ">=0.9.4" +virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2,<20.0.3 || >20.0.3,<20.0.4 || >20.0.4,<20.0.5 || >20.0.5,<20.0.6 || >20.0.6,<20.0.7 || >20.0.7" + +[package.dependencies.importlib-metadata] +python = "<3.8" +version = ">=0.12,<2" + +[package.extras] +docs = ["sphinx (>=2.0.0,<3)", "towncrier (>=18.5.0)", "pygments-github-lexers (>=0.0.5)", "sphinxcontrib-autoprogram (>=0.1.5)"] +testing = ["freezegun (>=0.3.11,<1)", "pathlib2 (>=2.3.3,<3)", "pytest (>=4.0.0,<6)", "pytest-cov (>=2.5.1,<3)", "pytest-mock (>=1.10.0,<2)", "pytest-xdist (>=1.22.2,<2)", "pytest-randomly (>=1.0.0,<4)", "flaky (>=3.4.0,<4)", "psutil (>=5.6.1,<6)"] + +[[package]] +category = "main" +description = "Virtual Python Environment builder" +name = "virtualenv" +optional = true +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +version = "20.0.20" + +[package.dependencies] +appdirs = ">=1.4.3,<2" +distlib = ">=0.3.0,<1" +filelock = ">=3.0.0,<4" +six = ">=1.9.0,<2" + +[package.dependencies.importlib-metadata] +python = "<3.8" +version = ">=0.12,<2" + +[package.dependencies.importlib-resources] +python = "<3.7" +version = ">=1.0,<2" + +[package.extras] +docs = ["sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)", "proselint (>=0.10.2)"] +testing = ["pytest (>=4)", "coverage (>=5)", "coverage-enable-subprocess (>=1)", "pytest-xdist (>=1.31.0)", "pytest-mock (>=2)", "pytest-env (>=0.6.2)", "pytest-randomly (>=1)", "pytest-timeout", "packaging (>=20.0)", "xonsh (>=0.9.16)"] + +[[package]] +category = "main" +description = "Measures number of Terminal column cells of wide-character codes" +name = "wcwidth" +optional = true +python-versions = "*" +version = "0.1.9" + [[package]] category = "main" description = "Backport of pathlib-compatible object wrapper for zip files" @@ -91,15 +344,79 @@ version = "3.1.0" docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] +[extras] +dev = ["bump2version", "tox"] +test = ["pytest", "pytest-cov", "hypothesis"] + [metadata] -content-hash = "dfd72de68240eac76c80e0ceb594465acd068b5de51b98c92173040148bfea53" +content-hash = "f97e1d927a9d63080cfb692d6ec34a495e5b16d939663d37641df0784de2eb5b" python-versions = "^3.6" [metadata.files] +appdirs = [ + {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, + {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, +] +atomicwrites = [ + {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, + {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, +] attrs = [ {file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"}, {file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"}, ] +bump2version = [ + {file = "bump2version-1.0.0-py2.py3-none-any.whl", hash = "sha256:477f0e18a0d58e50bb3dbc9af7fcda464fd0ebfc7a6151d8888602d7153171a0"}, + {file = "bump2version-1.0.0.tar.gz", hash = "sha256:cd4f3a231305e405ed8944d8ff35bd742d9bc740ad62f483bd0ca21ce7131984"}, +] +colorama = [ + {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, + {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"}, +] +coverage = [ + {file = "coverage-5.1-cp27-cp27m-macosx_10_12_x86_64.whl", hash = "sha256:0cb4be7e784dcdc050fc58ef05b71aa8e89b7e6636b99967fadbdba694cf2b65"}, + {file = "coverage-5.1-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:c317eaf5ff46a34305b202e73404f55f7389ef834b8dbf4da09b9b9b37f76dd2"}, + {file = "coverage-5.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b83835506dfc185a319031cf853fa4bb1b3974b1f913f5bb1a0f3d98bdcded04"}, + {file = "coverage-5.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:5f2294dbf7875b991c381e3d5af2bcc3494d836affa52b809c91697449d0eda6"}, + {file = "coverage-5.1-cp27-cp27m-win32.whl", hash = "sha256:de807ae933cfb7f0c7d9d981a053772452217df2bf38e7e6267c9cbf9545a796"}, + {file = "coverage-5.1-cp27-cp27m-win_amd64.whl", hash = "sha256:bf9cb9a9fd8891e7efd2d44deb24b86d647394b9705b744ff6f8261e6f29a730"}, + {file = "coverage-5.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:acf3763ed01af8410fc36afea23707d4ea58ba7e86a8ee915dfb9ceff9ef69d0"}, + {file = "coverage-5.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:dec5202bfe6f672d4511086e125db035a52b00f1648d6407cc8e526912c0353a"}, + {file = "coverage-5.1-cp35-cp35m-macosx_10_12_x86_64.whl", hash = "sha256:7a5bdad4edec57b5fb8dae7d3ee58622d626fd3a0be0dfceda162a7035885ecf"}, + {file = "coverage-5.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:1601e480b9b99697a570cea7ef749e88123c04b92d84cedaa01e117436b4a0a9"}, + {file = "coverage-5.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:dbe8c6ae7534b5b024296464f387d57c13caa942f6d8e6e0346f27e509f0f768"}, + {file = "coverage-5.1-cp35-cp35m-win32.whl", hash = "sha256:a027ef0492ede1e03a8054e3c37b8def89a1e3c471482e9f046906ba4f2aafd2"}, + {file = "coverage-5.1-cp35-cp35m-win_amd64.whl", hash = "sha256:0e61d9803d5851849c24f78227939c701ced6704f337cad0a91e0972c51c1ee7"}, + {file = "coverage-5.1-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:2d27a3f742c98e5c6b461ee6ef7287400a1956c11421eb574d843d9ec1f772f0"}, + {file = "coverage-5.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:66460ab1599d3cf894bb6baee8c684788819b71a5dc1e8fa2ecc152e5d752019"}, + {file = "coverage-5.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:5c542d1e62eece33c306d66fe0a5c4f7f7b3c08fecc46ead86d7916684b36d6c"}, + {file = "coverage-5.1-cp36-cp36m-win32.whl", hash = "sha256:2742c7515b9eb368718cd091bad1a1b44135cc72468c731302b3d641895b83d1"}, + {file = "coverage-5.1-cp36-cp36m-win_amd64.whl", hash = "sha256:dead2ddede4c7ba6cb3a721870f5141c97dc7d85a079edb4bd8d88c3ad5b20c7"}, + {file = "coverage-5.1-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:01333e1bd22c59713ba8a79f088b3955946e293114479bbfc2e37d522be03355"}, + {file = "coverage-5.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:e1ea316102ea1e1770724db01998d1603ed921c54a86a2efcb03428d5417e489"}, + {file = "coverage-5.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:adeb4c5b608574a3d647011af36f7586811a2c1197c861aedb548dd2453b41cd"}, + {file = "coverage-5.1-cp37-cp37m-win32.whl", hash = "sha256:782caea581a6e9ff75eccda79287daefd1d2631cc09d642b6ee2d6da21fc0a4e"}, + {file = "coverage-5.1-cp37-cp37m-win_amd64.whl", hash = "sha256:00f1d23f4336efc3b311ed0d807feb45098fc86dee1ca13b3d6768cdab187c8a"}, + {file = "coverage-5.1-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:402e1744733df483b93abbf209283898e9f0d67470707e3c7516d84f48524f55"}, + {file = "coverage-5.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:a3f3654d5734a3ece152636aad89f58afc9213c6520062db3978239db122f03c"}, + {file = "coverage-5.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6402bd2fdedabbdb63a316308142597534ea8e1895f4e7d8bf7476c5e8751fef"}, + {file = "coverage-5.1-cp38-cp38-win32.whl", hash = "sha256:8fa0cbc7ecad630e5b0f4f35b0f6ad419246b02bc750de7ac66db92667996d24"}, + {file = "coverage-5.1-cp38-cp38-win_amd64.whl", hash = "sha256:79a3cfd6346ce6c13145731d39db47b7a7b859c0272f02cdb89a3bdcbae233a0"}, + {file = "coverage-5.1-cp39-cp39-win32.whl", hash = "sha256:a82b92b04a23d3c8a581fc049228bafde988abacba397d57ce95fe95e0338ab4"}, + {file = "coverage-5.1-cp39-cp39-win_amd64.whl", hash = "sha256:bb28a7245de68bf29f6fb199545d072d1036a1917dca17a1e75bbb919e14ee8e"}, + {file = "coverage-5.1.tar.gz", hash = "sha256:f90bfc4ad18450c80b024036eaf91e4a246ae287701aaa88eaebebf150868052"}, +] +distlib = [ + {file = "distlib-0.3.0.zip", hash = "sha256:2e166e231a26b36d6dfe35a48c4464346620f8645ed0ace01ee31822b288de21"}, +] +filelock = [ + {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, + {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, +] +hypothesis = [ + {file = "hypothesis-5.14.0-py3-none-any.whl", hash = "sha256:ca58d0d6b37b10c9ae5cfaa97fb9cb8b033f0cbacb19dfea3fed79a7d57cfcfd"}, + {file = "hypothesis-5.14.0.tar.gz", hash = "sha256:0c00645c63341f035951cade156112d7c4d3c650c5a54671d104dc8acd41a350"}, +] importlib-metadata = [ {file = "importlib_metadata-1.6.0-py2.py3-none-any.whl", hash = "sha256:2a688cbaa90e0cc587f1df48bdc97a6eadccdcd9c35fb3f976a09e3b5016d90f"}, {file = "importlib_metadata-1.6.0.tar.gz", hash = "sha256:34513a8a0c4962bc66d35b359558fd8a5e10cd472d37aec5f66858addef32c1e"}, @@ -108,6 +425,34 @@ importlib-resources = [ {file = "importlib_resources-1.5.0-py2.py3-none-any.whl", hash = "sha256:85dc0b9b325ff78c8bef2e4ff42616094e16b98ebd5e3b50fe7e2f0bbcdcde49"}, {file = "importlib_resources-1.5.0.tar.gz", hash = "sha256:6f87df66833e1942667108628ec48900e02a4ab4ad850e25fbf07cb17cf734ca"}, ] +more-itertools = [ + {file = "more-itertools-8.3.0.tar.gz", hash = "sha256:558bb897a2232f5e4f8e2399089e35aecb746e1f9191b6584a151647e89267be"}, + {file = "more_itertools-8.3.0-py3-none-any.whl", hash = "sha256:7818f596b1e87be009031c7653d01acc46ed422e6656b394b0f765ce66ed4982"}, +] +packaging = [ + {file = "packaging-20.3-py2.py3-none-any.whl", hash = "sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752"}, + {file = "packaging-20.3.tar.gz", hash = "sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3"}, +] +pluggy = [ + {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, + {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, +] +py = [ + {file = "py-1.8.1-py2.py3-none-any.whl", hash = "sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0"}, + {file = "py-1.8.1.tar.gz", hash = "sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa"}, +] +pyparsing = [ + {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, + {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, +] +pytest = [ + {file = "pytest-5.4.2-py3-none-any.whl", hash = "sha256:95c710d0a72d91c13fae35dce195633c929c3792f54125919847fdcdf7caa0d3"}, + {file = "pytest-5.4.2.tar.gz", hash = "sha256:eb2b5e935f6a019317e455b6da83dd8650ac9ffd2ee73a7b657a30873d67a698"}, +] +pytest-cov = [ + {file = "pytest-cov-2.8.1.tar.gz", hash = "sha256:cc6742d8bac45070217169f5f72ceee1e0e55b0221f54bcf24845972d3a47f2b"}, + {file = "pytest_cov-2.8.1-py2.py3-none-any.whl", hash = "sha256:cdbdef4f870408ebdbfeb44e63e07eb18bb4619fae852f6e760645fa36172626"}, +] python-dateutil = [ {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, @@ -116,10 +461,30 @@ six = [ {file = "six-1.14.0-py2.py3-none-any.whl", hash = "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"}, {file = "six-1.14.0.tar.gz", hash = "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a"}, ] +sortedcontainers = [ + {file = "sortedcontainers-2.1.0-py2.py3-none-any.whl", hash = "sha256:d9e96492dd51fae31e60837736b38fe42a187b5404c16606ff7ee7cd582d4c60"}, + {file = "sortedcontainers-2.1.0.tar.gz", hash = "sha256:974e9a32f56b17c1bac2aebd9dcf197f3eb9cd30553c5852a3187ad162e1a03a"}, +] tatsu = [ {file = "TatSu-4.4.0-py2.py3-none-any.whl", hash = "sha256:c9211eeee9a2d4c90f69879ec0b518b1aa0d9450249cb0dd181f5f5b18be0a92"}, {file = "TatSu-4.4.0.zip", hash = "sha256:80713413473a009f2081148d0f494884cabaf9d6866b71f2a68a92b6442f343d"}, ] +toml = [ + {file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"}, + {file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"}, +] +tox = [ + {file = "tox-3.15.0-py2.py3-none-any.whl", hash = "sha256:8d97bfaf70053ed3db56f57377288621f1bcc7621446d301927d18df93b1c4c3"}, + {file = "tox-3.15.0.tar.gz", hash = "sha256:af09c19478e8fc7ce7555b3d802ddf601b82684b874812c5857f774b8aee1b67"}, +] +virtualenv = [ + {file = "virtualenv-20.0.20-py2.py3-none-any.whl", hash = "sha256:b4c14d4d73a0c23db267095383c4276ef60e161f94fde0427f2f21a0132dde74"}, + {file = "virtualenv-20.0.20.tar.gz", hash = "sha256:fd0e54dec8ac96c1c7c87daba85f0a59a7c37fe38748e154306ca21c73244637"}, +] +wcwidth = [ + {file = "wcwidth-0.1.9-py2.py3-none-any.whl", hash = "sha256:cafe2186b3c009a04067022ce1dcd79cb38d8d65ee4f4791b8888d6599d1bbe1"}, + {file = "wcwidth-0.1.9.tar.gz", hash = "sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1"}, +] zipp = [ {file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"}, {file = "zipp-3.1.0.tar.gz", hash = "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"}, diff --git a/pyproject.toml b/pyproject.toml index 1c2cf969..74e3e072 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,19 @@ attrs = ">=19.2" tatsu = ">4.2" importlib_resources = "^1.4" +# extra: test +pytest = { version = "^5.2", optional = true } +pytest-cov = { version = "^2.8.1", optional = true } +hypothesis = { version = "^5.8.0", optional = true } + +# extra: dev +bump2version = { version = "^1.0.0", optional = true } +tox = { version = "^3.15.0", optional = true } + +[tool.poetry.extras] +test = ["pytest", "pytest-cov", "hypothesis"] +dev = ["bump2version", "tox"] + [build-system] requires = ["poetry>=0.12"] build-backend = "poetry.masonry.api" diff --git a/src/ics/timespan.py b/src/ics/timespan.py index 7447dd61..1fdd0173 100644 --- a/src/ics/timespan.py +++ b/src/ics/timespan.py @@ -6,7 +6,8 @@ from dateutil.tz import tzlocal from ics.types import DatetimeLike -from ics.utils import TIMEDELTA_CACHE, TIMEDELTA_DAY, TIMEDELTA_ZERO, ceil_datetime_to_midnight, ensure_datetime, floor_datetime_to_midnight, timedelta_nearly_zero +from ics.utils import TIMEDELTA_CACHE, TIMEDELTA_DAY, TIMEDELTA_ZERO, ceil_datetime_to_midnight, ensure_datetime, \ + floor_datetime_to_midnight, timedelta_nearly_zero if TYPE_CHECKING: # Literal is new in python 3.8, but backported via typing_extensions @@ -26,14 +27,14 @@ def normalize(self, value: "Timespan") -> "Timespan": # pyflakes < 2.2 reports 'redefinition of unused' for overloaded class members @overload - def normalize(self, value: DatetimeLike) -> datetime: # noqa: F811 + def normalize(self, value: DatetimeLike) -> datetime: ... @overload - def normalize(self, value: None) -> None: # noqa: F811 + def normalize(self, value: None) -> None: ... - def normalize(self, value): # noqa: F811 + def normalize(self, value): """ Normalize datetime or timespan instances to make naive/floating ones (without timezone, i.e. tzinfo == None) comparable to aware ones with a fixed timezone. @@ -312,10 +313,10 @@ def timespan_tuple(self, default: None = None, normalization: Normalization = No ... @overload - def timespan_tuple(self, default: datetime, normalization: Normalization = None) -> TimespanTuple: # noqa: F811 + def timespan_tuple(self, default: datetime, normalization: Normalization = None) -> TimespanTuple: ... - def timespan_tuple(self, default=None, normalization=None): # noqa: F811 + def timespan_tuple(self, default=None, normalization=None): if normalization: return TimespanTuple( normalization.normalize(self.get_begin() or default), diff --git a/tox.ini b/tox.ini index 73720f46..058017db 100644 --- a/tox.ini +++ b/tox.ini @@ -6,10 +6,9 @@ envlist = py36, py37, py38, pypy3, checks, docs description = Run the pytest suite setenv = PYTHONDEVMODE=1 -deps = - pytest - pytest-cov - hypothesis +extras = + test +# see pyproject.toml for the list of dependencies in the "test" extra commands = pytest -V python -c 'import sys, pkg_resources; dist = pkg_resources.get_distribution("ics"); print(repr(dist), dist.__dict__, sys.path)' @@ -17,14 +16,14 @@ commands = [testenv:flake8] description = Run the flake8 code style checks -deps = flake8 +deps = flake8>=3.8.1 commands = flake8 --version flake8 src/ [testenv:mypy] description = Run the mypy type checks -deps = mypy +deps = mypy>=0.770 commands = mypy -V mypy --config-file=tox.ini src/