Repository: peopledoc/workalendar (python)

Issue:
add function to export calendar to iCal
I think it would be cool to have function that exports all holidays for a given year and jurisdiction in iCalendar format. I could implement this if it's within the intended scope of this module.

Pull request:
Added ICal export feature
closes #197


- [x] Tests with a all calendar instances possible.
- [x] Docstrings for the specific methods.
- [x] Changelog amended
- [x] Document the new feature

This PR superseeds and thus, closes #527

Patch:
diff --git a/Changelog.md b/Changelog.md
index ecc2a99..8805bcf 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -6,6 +6,9 @@
 
 - Added Mozambique calendar by @mr-shovel (#542).
 
+### New feature
+
+- Added iCal export feature, initiated by @joooeey (#197).
 
 ## v10.4.0 (2020-08-28)
 
diff --git a/docs/advanced.md b/docs/advanced.md
index e815965..2633c59 100644
--- a/docs/advanced.md
+++ b/docs/advanced.md
@@ -1,6 +1,6 @@
 # Advanced usage
 
-[Home](index.md) / [Basic usage](basic.md) / [ISO Registry](iso-registry.md)
+[Home](index.md) / [Basic usage](basic.md) / [ISO Registry](iso-registry.md) / [iCal Export](ical.md)
 
 The following examples will better suit people willing to contribute to Workalendar or building their custom calendars. They use primitives and methods attached to the core Calendar class to enable computation of complex holidays, that is to say dates that are not fixed, not related to religious calendars (Christmas always happens on December 25th, right?).
 
@@ -82,4 +82,4 @@ Colombia, as an example, states that the Epiphany Day is set to the "First Monda
 datetime.date(2018, 1, 8)
 ```
 
-[Home](index.md) / [Basic usage](basic.md) / [ISO Registry](iso-registry.md)
+[Home](index.md) / [Basic usage](basic.md) / [ISO Registry](iso-registry.md) / [iCal Export](ical.md)
diff --git a/docs/basic.md b/docs/basic.md
index 49a4d53..153fe8a 100644
--- a/docs/basic.md
+++ b/docs/basic.md
@@ -1,6 +1,6 @@
 # Basic usage
 
-[Home](index.md) / [Advanced usage](advanced.md) / [ISO Registry](iso-registry.md)
+[Home](index.md) / [Advanced usage](advanced.md) / [ISO Registry](iso-registry.md) / [iCal Export](ical.md)
 
 Here are basic examples of what Workalendar can do for you. As an integrator or a simple Workalendar user, you will use these methods to retrieve calendars, and get basic outputs for a given date.
 
@@ -109,4 +109,4 @@ If you really need it, you can use the ``add_working_days()`` with an option tha
 datetime.datetime(2012, 12, 31, 14, 0, 39)
 ```
 
-[Home](index.md) / [Advanced usage](advanced.md) / [ISO Registry](iso-registry.md)
+[Home](index.md) / [Advanced usage](advanced.md) / [ISO Registry](iso-registry.md) / [iCal Export](ical.md)
diff --git a/docs/ical.md b/docs/ical.md
new file mode 100644
index 0000000..e0edac8
--- /dev/null
+++ b/docs/ical.md
@@ -0,0 +1,71 @@
+# iCal export
+
+[Home](index.md) / [Basic usage](basic.md) / [Advanced usage](advanced.md) / [ISO Registry](iso-registry.md)
+
+As of [next version], you can export holidays generated by any Workalendar class to the iCal file format.
+
+## Export and retrieve the content
+
+In this example, we export all the public holidays for France from the year 2019 to the year 2022 (inclusive).
+
+```python
+>>> from workalendar.europe import France
+>>> cal = France()
+>>> ical_content = cal.export_to_ical(period=[2019, 2022])
+>>> print(ical_content)
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//workalendar//ical 9.0.0//EN
+BEGIN:VEVENT
+SUMMARY:New year
+DTSTART;VALUE=DATE:20190101
+DTSTAMP;VALUE=DATE-TIME:20200828T152009Z
+UID:2019-01-01New year@peopledoc.github.io/workalendar
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY:Easter Monday
+DTSTART;VALUE=DATE:20190422
+DTSTAMP;VALUE=DATE-TIME:20200828T152009Z
+UID:2019-04-22Easter Monday@peopledoc.github.io/workalendar
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY:Labour Day
+DTSTART;VALUE=DATE:20190501
+DTSTAMP;VALUE=DATE-TIME:20200828T152009Z
+UID:2019-05-01Labour Day@peopledoc.github.io/workalendar
+END:VEVENT
+# ...
+```
+
+You may store this content into a file, or do anything else you want with it.
+
+If you don't provide any period, it defaults to [2000, 2030].
+
+## Export and save it to a file
+
+You can also directly save your ical export to a file. The following example writes a ``france.ics`` file to your disk, with the holidays between 2019 and 2022.
+
+```python
+>>> from workalendar.europe import France
+>>> cal = France()
+>>> cal.export_to_ical(period=[2019, 2022], target_path="france.ics")
+```
+
+**Note:** Please note that the export function won't work if the path is unwriteable or if it points at an existing directory.
+
+### Known file extensions
+
+The `target_path` argument may be provided with or without a file extension. For example, it can be either:
+
+| `target_path` argument | filename generated |
+|:-----------------------|:-------------------|
+| `france`               | `france.ics`       |
+| `france.ics`           | `france.ics`       |
+| `france.ical`          | `france.ical`      |
+| `france.ifb`           | `france.ifb`       |
+| `france.icalendar`     | `france.icalendar` |
+| `france.txt`           | `france.txt.ics`   |
+
+As you see, we only add the `.ics` extension if the current `target_path` extension is not known.
+
+[Home](index.md) / [Basic usage](basic.md) / [Advanced usage](advanced.md) / [ISO Registry](iso-registry.md)
diff --git a/docs/index.md b/docs/index.md
index c8221e7..0b71fe8 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -17,3 +17,4 @@ See [the repository README](https://github.com/peopledoc/workalendar#available-c
 * [Basic usage](basic.md)
 * [Advanced usage](advanced.md)
 * [ISO Registry](iso-registry.md)
+* [iCal Export](ical.md)
diff --git a/docs/iso-registry.md b/docs/iso-registry.md
index 847078c..92cd181 100644
--- a/docs/iso-registry.md
+++ b/docs/iso-registry.md
@@ -1,6 +1,6 @@
 # The ISO registry
 
-[Home](index.md) / [Basic usage](basic.md) / [Advanced usage](advanced.md)
+[Home](index.md) / [Basic usage](basic.md) / [Advanced usage](advanced.md) / [iCal Export](ical.md)
 
 As of version 3.0 (August/September 2018), we have introduced a global calendar registry for calendars related to a country or a region that belongs to the [ISO 3166-1](https://en.wikipedia.org/wiki/ISO_3166-1) or the [ISO 3166-2](https://en.wikipedia.org/wiki/ISO_3166-2) for sub-regions (such as USA states, Australian territories or Canadian provinces and such).
 
@@ -150,4 +150,4 @@ As an example, the United States of America, or Australia and others are divided
 
 *Note*: this function will return an empty `dict` if the code is unknown.
 
-[Home](index.md) / [Basic usage](basic.md) / [Advanced usage](advanced.md)
+[Home](index.md) / [Basic usage](basic.md) / [Advanced usage](advanced.md) / [iCal Export](ical.md)
diff --git a/tox.ini b/tox.ini
index e6f7818..1e55b81 100644
--- a/tox.ini
+++ b/tox.ini
@@ -6,6 +6,7 @@ deps =
     pytest
     pandas
     pytest-cov
+    freezegun
 
 commands_pre =
     python setup.py develop
diff --git a/workalendar/core.py b/workalendar/core.py
index 8f77196..3119000 100644
--- a/workalendar/core.py
+++ b/workalendar/core.py
@@ -2,6 +2,8 @@
 Working day tools
 """
 from copy import copy
+import os
+from os.path import isdir
 import warnings
 from calendar import monthrange
 from datetime import date, timedelta, datetime
@@ -10,7 +12,10 @@ from calverter import Calverter
 from dateutil import easter
 from lunardate import LunarDate
 
-from .exceptions import UnsupportedDateType, CalendarError
+from .exceptions import (
+    UnsupportedDateType, CalendarError,
+    ICalExportRangeError, ICalExportTargetPathError
+)
 
 MON, TUE, WED, THU, FRI, SAT, SUN = range(7)
 
@@ -793,6 +798,124 @@ class CoreCalendar:
                 count += 1
         return count
 
+    def _get_ical_period(self, period=None):
+        """
+        Return a usable period for iCal export
+
+        Default period is [2000, 2030]
+        """
+        # Default value.
+        if not period:
+            period = [2000, 2030]
+
+        # Make sure it's a usable iterable
+        if type(period) not in (list, tuple):
+            raise ICalExportRangeError(
+                "Incorrect Range type. Must be list or tuple.")
+
+        # Taking the extremes
+        period = [min(period), max(period)]
+
+        # check for internal types
+        check_types = map(type, period)
+        check_types = map(lambda x: x != int, check_types)
+        if any(check_types):
+            raise ICalExportRangeError(
+                "Incorrect Range boundaries. Must be int.")
+
+        return period
+
+    def _get_ical_target_path(self, target_path):
+        """
+        Return target path for iCal export.
+
+        Note
+        ----
+        If `target_path` does not have one of the extensions `.ical`, `.ics`,
+        `.ifb`, or `.icalendar`, the extension `.ics` is appended to the path.
+        Returns
+        -------
+        None.
+        Examples
+        --------
+        >>> cal = Austria()
+        >>> cal._get_ical_target_path('austria')  # -> austria.ics
+
+        """
+
+        if not target_path:
+            raise ICalExportTargetPathError(
+                "Incorrect target path. It must not be empty")
+
+        if isdir(target_path):
+            raise ICalExportTargetPathError(
+                "Incorrect target path. It must not be a directory"
+            )
+
+        ical_extensions = ['.ical', '.ics', '.ifb', '.icalendar']
+        if os.path.splitext(target_path)[1] not in ical_extensions:
+            target_path += '.ics'
+        return target_path
+
+    def export_to_ical(self, period=[2000, 2030], target_path=None):
+        """
+        Export the calendar to iCal (RFC 5545) format.
+
+        Parameters
+        ----------
+        period: [int, int]
+            start and end year (inclusive) of calendar
+            Default is [2000, 2030]
+
+        target_path: str
+            the name or path of the exported file. If this argument is missing,
+            the function will return the ical content.
+
+        """
+        first_year, last_year = self._get_ical_period(period)
+        if target_path:
+            # Generate filename path before calculate the holidays
+            target_path = self._get_ical_target_path(target_path)
+
+        # fetch holidays
+        holidays = []
+        for year in range(first_year, last_year + 1):
+            holidays.extend(self.holidays(year))
+
+        # initialize icalendar
+        ics = [
+            'BEGIN:VCALENDAR',
+            'VERSION:2.0',  # current RFC5545 version
+            'PRODID:-//workalendar//ical 9.0.0//EN'
+        ]
+        common_timestamp = datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')
+        dtstamp = 'DTSTAMP;VALUE=DATE-TIME:%s' % common_timestamp
+
+        # add an event for each holiday
+        for date_, name in holidays:
+            ics.extend([
+                'BEGIN:VEVENT',
+                'SUMMARY:%s' % name,
+                'DTSTART;VALUE=DATE:%s' % date_.strftime('%Y%m%d'),
+                dtstamp,
+                'UID:%s%s@peopledoc.github.io/workalendar' % (date_, name),
+                'END:VEVENT',
+            ])
+
+        # add footer
+        ics.append('END:VCALENDAR\n')  # last line with a trailing \n
+
+        # Transform this list into text lines
+        ics = "\n".join(ics)
+
+        if target_path:
+            # save iCal file
+            with open(target_path, 'w+') as export_file:
+                export_file.write(ics)
+            return
+
+        return ics
+
 
 class Calendar(CoreCalendar):
     """
diff --git a/workalendar/exceptions.py b/workalendar/exceptions.py
index 6cab6f6..d460083 100644
--- a/workalendar/exceptions.py
+++ b/workalendar/exceptions.py
@@ -19,3 +19,21 @@ class ISORegistryError(CalendarError):
     """
     Raised when you are trying to register a non-Calendar object
     """
+
+
+class ICalExportError(CalendarError):
+    """
+    Raised when the iCal export is impossible
+    """
+
+
+class ICalExportRangeError(ICalExportError):
+    """
+    Raised when the iCal export is impossible due to wrong range
+    """
+
+
+class ICalExportTargetPathError(ICalExportError):
+    """
+    Raised when the iCal export is impossible due to unusable target path
+    """


Test patch:
diff --git a/workalendar/tests/__init__.py b/workalendar/tests/__init__.py
index 98c889a..afdddcd 100644
--- a/workalendar/tests/__init__.py
+++ b/workalendar/tests/__init__.py
@@ -1,7 +1,12 @@
+import tempfile
+import os
+from os.path import join
 import warnings
 from datetime import date
 from unittest import TestCase
 
+from freezegun import freeze_time
+
 from ..core import Calendar
 
 
@@ -23,7 +28,7 @@ class GenericCalendarTest(CoreCalendarTest):
 
     def test_weekend_days(self):
         class_name = self.cal_class.__name__
-        if class_name in ('Calendar',):
+        if class_name in ['Calendar']:
             return
         try:
             self.cal.get_weekend_days()
@@ -32,10 +37,82 @@ class GenericCalendarTest(CoreCalendarTest):
 
     def test_january_1st(self):
         class_name = self.cal_class.__name__
-        if class_name in ('Calendar',):
+        if class_name in ['Calendar']:
             return
         holidays = self.cal.holidays_set(self.year)
         if self.test_include_january_1st:
             self.assertIn(date(self.year, 1, 1), holidays)
         else:
             self.assertNotIn(date(self.year, 1, 1), holidays)
+
+    def test_ical_export(self):
+        """Check that an iCal file can be created according to iCal spec."""
+        class_name = self.cal_class.__name__
+        if class_name in ['Calendar']:
+            return
+
+        holidays = self.cal.holidays(2019) + self.cal.holidays(2020)
+
+        temp_dir = join(tempfile.gettempdir(), "failed_ical_tests")
+        os.makedirs(temp_dir, exist_ok=True)
+        _, test_file_name = tempfile.mkstemp(
+            prefix="%s_" % self.cal_class.__name__,
+            suffix=".ics",
+            dir=temp_dir,
+        )
+
+        self.cal.export_to_ical(
+            period=[2019, 2020],
+            target_path=test_file_name,
+        )
+        # A standard iCal extension should have been added automatically:
+        with open(test_file_name) as ics_file:
+            # check header
+            assert ics_file.readline() == 'BEGIN:VCALENDAR\n'
+            assert ics_file.readline() == 'VERSION:2.0\n'
+            assert ics_file.readline().startswith(
+                'PRODID:-//workalendar//ical ')
+            # check new year
+            assert ics_file.readline() == 'BEGIN:VEVENT\n'
+            first_event_name = holidays[0][1]
+            assert ics_file.readline() == 'SUMMARY:%s\n' % first_event_name
+            if self.test_include_january_1st:
+                assert ics_file.readline() == 'DTSTART;VALUE=DATE:20190101\n'
+                assert ics_file.readline().startswith(
+                    'DTSTAMP;VALUE=DATE-TIME:')
+                first_uid_line = ics_file.readline()
+                uid_lines = [first_uid_line]
+                assert first_uid_line.startswith('UID:2019')
+                assert ics_file.readline() == 'END:VEVENT\n'
+            else:
+                uid_lines = []
+            remaining_lines = ics_file.readlines()
+            uid_lines += [line for line in remaining_lines
+                          if line.startswith('UID:')]
+            # check number of entries
+            assert len(uid_lines) == len(holidays)
+            # check that UIDs are unique within the calendar
+            assert len(uid_lines) == len(set(uid_lines))
+            # check that final year is included
+            assert remaining_lines[-3].startswith('UID:2020')
+            # check last few lines of file
+            assert remaining_lines[-2] == 'END:VEVENT\n'
+            assert remaining_lines[-1] == 'END:VCALENDAR\n'
+
+        # Freeze time to make sure they both have the same timestamp
+        with freeze_time():
+            # Regenerate the file
+            self.cal.export_to_ical(
+                period=[2019, 2020],
+                target_path=test_file_name,
+            )
+            # Not providing any target => returns the value as a string
+            var_contents = self.cal.export_to_ical(period=[2019, 2020])
+
+        with open(test_file_name) as fd:
+            file_contents = fd.read()
+
+        assert file_contents == var_contents
+
+        # Remove the .ics file if this test passes
+        os.remove(test_file_name)
diff --git a/workalendar/tests/test_ical_export.py b/workalendar/tests/test_ical_export.py
new file mode 100644
index 0000000..a404643
--- /dev/null
+++ b/workalendar/tests/test_ical_export.py
@@ -0,0 +1,141 @@
+import tempfile
+import os
+from os.path import join
+
+from ..core import Calendar
+from ..exceptions import ICalExportRangeError, ICalExportTargetPathError
+
+from . import CoreCalendarTest
+
+
+class FakeCalendar(Calendar):
+    """Fake Calendar"""
+
+
+class IcalExportPeriodTest(CoreCalendarTest):
+    cal_class = FakeCalendar
+
+    def test_empty_range(self):
+        # No period means 2000-2030
+        result = self.cal._get_ical_period()
+        self.assertEqual(len(result), 2)
+        self.assertEqual(result[0], 2000)
+        self.assertEqual(result[1], 2030)
+        # No period means 2000-2030
+        result = self.cal._get_ical_period(period=())
+        self.assertEqual(len(result), 2)
+        self.assertEqual(result[0], 2000)
+        self.assertEqual(result[1], 2030)
+        # No period means 2000-2030
+        result = self.cal._get_ical_period(period=[])
+        self.assertEqual(len(result), 2)
+        self.assertEqual(result[0], 2000)
+        self.assertEqual(result[1], 2030)
+
+    def test_range_lists(self):
+        # Period: 2010-2020
+        result = self.cal._get_ical_period(period=[2010, 2020])
+        self.assertEqual(len(result), 2)
+        self.assertEqual(result[0], 2010)
+        self.assertEqual(result[1], 2020)
+
+        # Bad period: 2020-2010 => Swap terms
+        result = self.cal._get_ical_period(period=[2020, 2010])
+        self.assertEqual(len(result), 2)
+        self.assertEqual(result[0], 2010)
+        self.assertEqual(result[1], 2020)
+
+        # Too long of a list => take the extremes
+        result = self.cal._get_ical_period(period=[2010, 2015, 2020])
+        self.assertEqual(len(result), 2)
+        self.assertEqual(result[0], 2010)
+        self.assertEqual(result[1], 2020)
+
+        # Assume that the range is only one year
+        result = self.cal._get_ical_period(period=[2020, 2020])
+        self.assertEqual(len(result), 2)
+        self.assertEqual(result[0], 2020)
+        self.assertEqual(result[1], 2020)
+
+    def test_range_tuples(self):
+        # Period: 2010-2020
+        result = self.cal._get_ical_period(period=(2010, 2020))
+        self.assertEqual(len(result), 2)
+        self.assertEqual(result[0], 2010)
+        self.assertEqual(result[1], 2020)
+
+        # Bad period: 2020-2010 => Swap terms
+        result = self.cal._get_ical_period(period=(2020, 2010))
+        self.assertEqual(len(result), 2)
+        self.assertEqual(result[0], 2010)
+        self.assertEqual(result[1], 2020)
+
+        # Too long of a list => take the extremes
+        result = self.cal._get_ical_period(period=(2010, 2015, 2020))
+        self.assertEqual(len(result), 2)
+        self.assertEqual(result[0], 2010)
+        self.assertEqual(result[1], 2020)
+
+        # Assume that the range is only one year
+        result = self.cal._get_ical_period(period=(2020, 2020))
+        self.assertEqual(len(result), 2)
+        self.assertEqual(result[0], 2020)
+        self.assertEqual(result[1], 2020)
+
+    def test_bad_ranges(self):
+        # Bad period: String
+        with self.assertRaises(ICalExportRangeError):
+            self.cal._get_ical_period(period="something")
+
+        # Bad period: Dict
+        with self.assertRaises(ICalExportRangeError):
+            self.cal._get_ical_period(period={"a": 2000, "b": "foobar"})
+
+        # Bad period: List of non-integers
+        with self.assertRaises(ICalExportRangeError):
+            self.cal._get_ical_period(period=["foo", "bar"])
+
+        # Bad period: Tuple of non-integers
+        with self.assertRaises(ICalExportRangeError):
+            self.cal._get_ical_period(period=("foo", "bar"))
+
+
+class ICalExportTargetPath(CoreCalendarTest):
+    cal_class = FakeCalendar
+
+    def test_empty_path(self):
+        with self.assertRaises(ICalExportTargetPathError):
+            self.cal._get_ical_target_path("")
+
+        with self.assertRaises(ICalExportTargetPathError):
+            self.cal._get_ical_target_path(None)
+
+    def test_target_is_directory(self):
+        temp_dir = join(tempfile.gettempdir(), "failed_ical_tests")
+        os.makedirs(temp_dir, exist_ok=True)
+
+        with self.assertRaises(ICalExportTargetPathError):
+            self.cal._get_ical_target_path(temp_dir)
+
+    def test_no_extension(self):
+        # Relative paths
+        self.assertEqual(self.cal._get_ical_target_path("a"), "a.ics")
+        self.assertEqual(self.cal._get_ical_target_path("a/b"), "a/b.ics")
+        # Absolute path
+        self.assertEqual(
+            self.cal._get_ical_target_path("/path/to/a"),
+            "/path/to/a.ics"
+        )
+
+    def test_known_extensions(self):
+        for ext in ('ical', 'ics', 'ifb', 'icalendar'):
+            filename = "a.{}".format(ext)
+            self.assertEqual(
+                self.cal._get_ical_target_path(filename),
+                filename
+            )
+
+    def test_added_extensions(self):
+        self.assertEqual(self.cal._get_ical_target_path('a.'), 'a..ics')
+        self.assertEqual(self.cal._get_ical_target_path('a.txt'), 'a.txt.ics')
+        self.assertEqual(self.cal._get_ical_target_path('.test'), '.test.ics')