commit ed9a4b3f15a20bc8d76c00008aadfd8ad9c4565c Author: Bruno Bord Date: Wed Nov 20 15:16:27 2013 +0100 I see France, I see the USA, I see holidays! diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f78cf5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.pyc + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a039725 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2013 Novapost + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..ec550f3 --- /dev/null +++ b/README.rst @@ -0,0 +1,20 @@ +=========== +Workalendar +=========== + +Overview +======== + +Workalendar is a Python module that offers classes able to handle calendars, +list legal / religious holidays and gives workday-related computation functions. + +Status +====== + +This is barely beta. Please consider this module as a work in progres. + +License +======= + +This library is published under the terms of the MIT License. Please check the +LICENSE file for more details. diff --git a/requirements.pip b/requirements.pip new file mode 100644 index 0000000..0f08daa --- /dev/null +++ b/requirements.pip @@ -0,0 +1 @@ +python-dateutil diff --git a/workalendar/__init__.py b/workalendar/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/workalendar/america.py b/workalendar/america.py new file mode 100644 index 0000000..badb3e6 --- /dev/null +++ b/workalendar/america.py @@ -0,0 +1,30 @@ +from workalendar.core import WesternCalendar +from workalendar.core import SUN, MON, THU +from datetime import date + + +class UnitedStatesCalendar(WesternCalendar): + "USA calendar" + + @staticmethod + def is_presidential_year(year): + return (year % 4) == 0 + + def get_calendar_holidays(self, year): + days = super(UnitedStatesCalendar, self).get_calendar_holidays(year) + days.add(date(year, 7, 4)) + days.add(date(year, 11, 11)) + # variable days + days.add(WesternCalendar.get_nth_weekday_in_month(year, 1, MON, 3)) + days.add(WesternCalendar.get_nth_weekday_in_month(year, 2, MON, 3)) + days.add(WesternCalendar.get_last_weekday_in_month(year, 5, MON)) + days.add(WesternCalendar.get_nth_weekday_in_month(year, 9, MON)) + days.add(WesternCalendar.get_nth_weekday_in_month(year, 10, MON, 2)) + days.add(WesternCalendar.get_nth_weekday_in_month(year, 11, THU, 4)) + # Inauguration day + if UnitedStatesCalendar.is_presidential_year(year - 1): + inauguration_day = date(year, 1, 20) + if inauguration_day.weekday() == SUN: + inauguration_day = date(year, 1, 21) + days.add(inauguration_day) + return days diff --git a/workalendar/core.py b/workalendar/core.py new file mode 100644 index 0000000..e3fcd39 --- /dev/null +++ b/workalendar/core.py @@ -0,0 +1,132 @@ +"""Workday tools +""" +from calendar import monthrange +from datetime import date, timedelta +from dateutil import easter + +MON, TUE, WED, THU, FRI, SAT, SUN = range(7) + + +class Calendar(object): + + EASTER_METHOD = 3 # 3 is 'Western' + + def __init__(self): + self._holidays = {} + + def get_calendar_holidays(self, year): + """Get calendar holidays. + This method **must** return a set or a list. + You must override this method for each calendar.""" + return set([]) + + def holidays(self, year=None): + "Computes holidays (non-working days) for a given year" + if not year: + year = date.today().year + + if year in self._holidays: + return self._holidays[year] + + if year not in self._holidays: + self._holidays[year] = set([]) + # Here we process the holiday specific calendar + self._holidays[year] = self.get_calendar_holidays(year) + return set(self._holidays[year]) + + def get_weekend_days(self): + """Return a list (or a tuple) of weekdays that are *not* workdays. + + e.g: return (SAT, SUN,) + + """ + raise NotImplementedError("Your Calendar class must implement the" + " `get_weekend_days` method") + + def is_workday(self, day): + "Return True if it's a workday." + if day.weekday() in self.get_weekend_days(): + return False + if day in self.holidays(day.year): + return False + return True + + def add_workdays(self, day, delta): + "Add `delta` workdays to the date." + days = 0 + temp_day = day + while days < delta: + temp_day = temp_day + timedelta(1) + if self.is_workday(temp_day): + days += 1 + return temp_day + + def get_easter_sunday(self, year): + "Return the date of the easter (sunday) -- following the easter method" + return easter.easter(year, self.EASTER_METHOD) + + def get_easter_monday(self, year): + "Return the date of the monday after easter" + sunday = self.get_easter_sunday(year) + return sunday + timedelta(days=1) + + @staticmethod + def get_nth_weekday_in_month(year, month, weekday, n=1): + """Get the nth weekday in a given month. e.g: + + >>> # the 1st monday in Jan 2013 + >>> Calendar.get_nth_weekday_in_month(2013, 1, MON) + datetime.date(2013, 1, 7) + >>> # The 2nd monday in Jan 2013 + >>> Calendar.get_nth_weekday_in_month(2013, 1, MON, 2) + datetime.date(2013, 1, 14) + """ + day = date(year, month, 1) + counter = 0 + while True: + if day.month != month: + # Don't forget to break if "n" is too big + return None + if day.weekday() == weekday: + counter += 1 + if counter == n: + break + day = day + timedelta(days=1) + return day + + @staticmethod + def get_last_weekday_in_month(year, month, weekday): + """Get the last weekday in a given month. e.g: + + >>> # the last monday in Jan 2013 + >>> Calendar.get_last_weekday_in_month(2013, 1, MON) + datetime.date(2013, 1, 28) + """ + day = date(year, month, monthrange(year, month)[1]) + while True: + if day.weekday() == weekday: + break + day = day - timedelta(days=1) + return day + + +class WesternCalendar(Calendar): + """ + General usage calendar for Western countries. + + (chiefly Europe and Northern America) + + """ + EASTER_METHOD = 3 # 3 is 'Western' + WEEK_END_DAYS = (SAT, SUN) + + def get_calendar_holidays(self, year): + "European countries have at least these 2 days as holidays in common" + days = set([]) + days.add(date(year, 1, 1)) + days.add(date(year, 12, 25)) + return days + + def get_weekend_days(self): + "Week-end days are SATurday and SUNday." + return self.WEEK_END_DAYS diff --git a/workalendar/europe.py b/workalendar/europe.py new file mode 100644 index 0000000..b0b2450 --- /dev/null +++ b/workalendar/europe.py @@ -0,0 +1,28 @@ +from workalendar.core import WesternCalendar +from datetime import date, timedelta + + +class FranceCalendar(WesternCalendar): + "France calendar class" + + def get_ascension_thursday(self, year): + easter = self.get_easter_sunday(year) + return easter + timedelta(days=39) + + def get_pentecote_monday(self, year): + easter = self.get_easter_sunday(year) + return easter + timedelta(days=50) + + def get_calendar_holidays(self, year): + days = super(FranceCalendar, self).get_calendar_holidays(year) + days.add(date(year, 5, 1)) # Labour day + days.add(date(year, 5, 8)) # 1939-45 victory + days.add(date(year, 7, 14)) # National day + days.add(date(year, 8, 15)) # Assomption + days.add(date(year, 11, 1)) # Toussaint + days.add(date(year, 11, 11)) # Armistice + days.add(date(year, 12, 25)) # Christmas + days.add(self.get_easter_monday(year)) + days.add(self.get_ascension_thursday(year)) + days.add(self.get_pentecote_monday(year)) + return days diff --git a/workalendar/tests/__init__.py b/workalendar/tests/__init__.py new file mode 100644 index 0000000..513c799 --- /dev/null +++ b/workalendar/tests/__init__.py @@ -0,0 +1,13 @@ +from datetime import date +from unittest import TestCase + +from workalendar.core import Calendar + + +class GenericCalendarTest(TestCase): + + cal_class = Calendar + + def setUp(self): + self.year = date.today().year + self.cal = self.cal_class() diff --git a/workalendar/tests/test_america.py b/workalendar/tests/test_america.py new file mode 100644 index 0000000..1140bff --- /dev/null +++ b/workalendar/tests/test_america.py @@ -0,0 +1,39 @@ +from datetime import date +from workalendar.tests import GenericCalendarTest +from workalendar.america import UnitedStatesCalendar + + +class UnitedStatesCalendarTest(GenericCalendarTest): + + cal_class = UnitedStatesCalendar + + def test_year_2013(self): + holidays = self.cal.holidays(2013) + self.assertIn(date(2013, 1, 1), holidays) # new year + self.assertIn(date(2013, 7, 4), holidays) # Nation day + self.assertIn(date(2013, 11, 11), holidays) # Armistice + self.assertIn(date(2013, 12, 25), holidays) # Christmas + # Variable days + self.assertIn(date(2013, 1, 21), holidays) # Martin Luther King + self.assertIn(date(2013, 2, 18), holidays) # Washington's bday + self.assertIn(date(2013, 5, 27), holidays) # Memorial day + self.assertIn(date(2013, 9, 2), holidays) # Labour day + self.assertIn(date(2013, 10, 14), holidays) # Colombus + self.assertIn(date(2013, 11, 28), holidays) # Thanskgiving + + def test_presidential_year(self): + self.assertTrue(UnitedStatesCalendar.is_presidential_year(2012)) + self.assertFalse(UnitedStatesCalendar.is_presidential_year(2013)) + self.assertFalse(UnitedStatesCalendar.is_presidential_year(2014)) + self.assertFalse(UnitedStatesCalendar.is_presidential_year(2015)) + self.assertTrue(UnitedStatesCalendar.is_presidential_year(2016)) + + def test_inauguration_day(self): + holidays = self.cal.holidays(2008) + self.assertNotIn(date(2008, 1, 20), holidays) + holidays = self.cal.holidays(2009) + self.assertIn(date(2009, 1, 20), holidays) + # case when inauguration day is a sunday + holidays = self.cal.holidays(1985) + self.assertNotIn(date(1985, 1, 20), holidays) + self.assertIn(date(1985, 1, 21), holidays) diff --git a/workalendar/tests/test_core.py b/workalendar/tests/test_core.py new file mode 100644 index 0000000..020770c --- /dev/null +++ b/workalendar/tests/test_core.py @@ -0,0 +1,67 @@ +from datetime import date +from workalendar.tests import GenericCalendarTest +from workalendar.core import Calendar, MON, TUE, THU + + +class CalendarTest(GenericCalendarTest): + + def test_private_variables(self): + self.assertTrue(hasattr(self.cal, '_holidays')) + private_holidays = self.cal._holidays + self.assertTrue(isinstance(private_holidays, dict)) + self.cal.holidays(2011) + self.cal.holidays(2012) + private_holidays = self.cal._holidays + self.assertTrue(isinstance(private_holidays, dict)) + self.assertIn(2011, self.cal._holidays) + self.assertIn(2012, self.cal._holidays) + + def test_year(self): + holidays = self.cal.holidays() + self.assertTrue(isinstance(holidays, set)) + self.assertEquals(self.cal._holidays[self.year], holidays) + + def test_another_year(self): + holidays = self.cal.holidays(2011) + self.assertTrue(isinstance(holidays, set)) + self.assertEquals(self.cal._holidays[2011], holidays) + + def test_is_workday(self): + self.assertRaises( + NotImplementedError, + self.cal.is_workday, date(2012, 1, 1)) + + def test_nth_weekday(self): + # first monday in january 2013 + self.assertEquals( + Calendar.get_nth_weekday_in_month(2013, 1, MON), + date(2013, 1, 7) + ) + # second monday in january 2013 + self.assertEquals( + Calendar.get_nth_weekday_in_month(2013, 1, MON, 2), + date(2013, 1, 14) + ) + # let's test the limits + # Jan 1st is a TUE + self.assertEquals( + Calendar.get_nth_weekday_in_month(2013, 1, TUE), + date(2013, 1, 1) + ) + # There's no 6th MONday + self.assertEquals( + Calendar.get_nth_weekday_in_month(2013, 1, MON, 6), + None + ) + + def test_last_weekday(self): + # last monday in january 2013 + self.assertEquals( + Calendar.get_last_weekday_in_month(2013, 1, MON), + date(2013, 1, 28) + ) + # last thursday + self.assertEquals( + Calendar.get_last_weekday_in_month(2013, 1, THU), + date(2013, 1, 31) + ) diff --git a/workalendar/tests/test_europe.py b/workalendar/tests/test_europe.py new file mode 100644 index 0000000..aa17e06 --- /dev/null +++ b/workalendar/tests/test_europe.py @@ -0,0 +1,35 @@ +from datetime import date +from workalendar.tests import GenericCalendarTest +from workalendar.europe import FranceCalendar + + +class FranceCalendarTest(GenericCalendarTest): + + cal_class = FranceCalendar + + def test_year_2013(self): + holidays = self.cal.holidays(2013) + self.assertIn(date(2013, 1, 1), holidays) # new year + self.assertIn(date(2013, 4, 1), holidays) # easter + self.assertIn(date(2013, 5, 1), holidays) # labour day + self.assertIn(date(2013, 5, 8), holidays) # 39-45 + self.assertIn(date(2013, 5, 9), holidays) # Ascension + self.assertIn(date(2013, 5, 20), holidays) # Pentecote + self.assertIn(date(2013, 7, 14), holidays) # Nation day + self.assertIn(date(2013, 8, 15), holidays) # Assomption + self.assertIn(date(2013, 11, 1), holidays) # Toussaint + self.assertIn(date(2013, 11, 11), holidays) # Armistice + self.assertIn(date(2013, 12, 25), holidays) # Christmas + + def test_workdays(self): + self.assertFalse(self.cal.is_workday(date(2013, 1, 1))) # holiday + self.assertFalse(self.cal.is_workday(date(2013, 1, 5))) # saturday + self.assertFalse(self.cal.is_workday(date(2013, 1, 6))) # sunday + self.assertTrue(self.cal.is_workday(date(2013, 1, 7))) # monday + + def test_business_days_computations(self): + day = date(2013, 10, 30) + self.assertEquals(self.cal.add_workdays(day, 0), date(2013, 10, 30)) + self.assertEquals(self.cal.add_workdays(day, 1), date(2013, 10, 31)) + self.assertEquals(self.cal.add_workdays(day, 2), date(2013, 11, 4)) + self.assertEquals(self.cal.add_workdays(day, 3), date(2013, 11, 5))