├── MANIFEST.in ├── businesstimedelta ├── requirements.txt ├── test │ ├── __init__.py │ ├── rules │ │ ├── __init__.py │ │ ├── rules_tests.py │ │ ├── rule_tests.py │ │ ├── workdayrule_tests.py │ │ └── holidayrule_tests.py │ └── businesstimedelta_tests.py ├── __init__.py ├── rules │ ├── __init__.py │ ├── rule.py │ ├── holidayrules.py │ ├── rules.py │ └── workdayrules.py └── businesstimedelta.py ├── setup.cfg ├── .travis.yml ├── .gitignore ├── LICENSE.txt ├── setup.py └── README.md /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md -------------------------------------------------------------------------------- /businesstimedelta/requirements.txt: -------------------------------------------------------------------------------- 1 | pytz -------------------------------------------------------------------------------- /businesstimedelta/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /businesstimedelta/test/rules/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md -------------------------------------------------------------------------------- /businesstimedelta/__init__.py: -------------------------------------------------------------------------------- 1 | from .rules import * 2 | from .businesstimedelta import * 3 | -------------------------------------------------------------------------------- /businesstimedelta/rules/__init__.py: -------------------------------------------------------------------------------- 1 | from .rule import * 2 | from .rules import * 3 | from .workdayrules import * 4 | from .holidayrules import * 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.4" 5 | - "3.5" 6 | - "3.6" 7 | - "3.7" 8 | # command to install dependencies 9 | install: "python setup.py install" 10 | # command to run tests 11 | script: nosetests -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | #Ipython Notebook 62 | .ipynb_checkpoints 63 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Sebastiaan Boer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setup( 7 | name='businesstimedelta', 8 | version='1.0.1', 9 | description="Timedelta for business time. Supports exact amounts of time " + 10 | "(hours, seconds), custom schedules, holidays, and time zones.", 11 | long_description=long_description, 12 | long_description_content_type="text/markdown", 13 | classifiers=[ 14 | 'Development Status :: 5 - Production/Stable', 15 | 'License :: OSI Approved :: MIT License', 16 | 'Programming Language :: Python :: 2.7', 17 | 'Programming Language :: Python :: 3.4', 18 | 'Programming Language :: Python :: 3.5', 19 | 'Programming Language :: Python :: 3.6', 20 | 'Programming Language :: Python :: 3.7', 21 | 'Topic :: Office/Business :: Scheduling' 22 | ], 23 | keywords='business working time timedelta hours businesstime businesshours', 24 | url='http://github.com/seppemans/businesstimedelta', 25 | author='seppemans', 26 | license='MIT', 27 | packages=['businesstimedelta', 'businesstimedelta.rules'], 28 | install_requires=[ 29 | 'pytz', 30 | 'holidays' 31 | ], 32 | zip_safe=False, 33 | test_suite='nose.collector', 34 | tests_require=['nose']) 35 | -------------------------------------------------------------------------------- /businesstimedelta/rules/rule.py: -------------------------------------------------------------------------------- 1 | import pytz 2 | import datetime 3 | from ..businesstimedelta import BusinessTimeDelta, localize_unlocalized_dt 4 | 5 | 6 | class Rule(object): 7 | """This object defines 'blocks' of time. It can define either working hours 8 | or an exclusion of working hours (such as holidays, lunch breaks, etc)""" 9 | def __init__(self, tz=pytz.utc, time_off=False): 10 | self.tz = tz 11 | self.time_off = time_off 12 | 13 | def next(self, dt): 14 | """Returns the start and end of the upcoming (or current) block of time 15 | that falls within this BusinessTime. 16 | 17 | Args: 18 | dt: a datetime object. 19 | Output: 20 | tuple of (start, end) of the first upcoming business time 21 | in aware datetime objects. 22 | """ 23 | raise NotImplementedError 24 | 25 | def previous(self, *args, **kwargs): 26 | """Same as next, but backwards in time""" 27 | raise NotImplementedError 28 | 29 | def difference(self, dt1, dt2): 30 | """Calculate the business time between two datetime objects.""" 31 | dt1 = localize_unlocalized_dt(dt1) 32 | dt2 = localize_unlocalized_dt(dt2) 33 | start_dt, end_dt = sorted([dt1, dt2]) 34 | td_sum = datetime.timedelta() 35 | dt = start_dt 36 | 37 | while True: 38 | period_start, period_end = self.next(dt) 39 | period_delta = period_end - period_start 40 | 41 | # If we are past the end_dt, we are done! 42 | if period_end > end_dt: 43 | last_day_add = max(end_dt - period_start, datetime.timedelta()) 44 | result = td_sum + last_day_add 45 | return BusinessTimeDelta(self, hours=result.days * 24, seconds=result.seconds) 46 | 47 | dt = period_end 48 | td_sum += period_delta 49 | -------------------------------------------------------------------------------- /businesstimedelta/rules/holidayrules.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from .rule import Rule 3 | from ..businesstimedelta import localize_unlocalized_dt 4 | 5 | 6 | class HolidayRule(Rule): 7 | def __init__(self, holidays, *args, **kwargs): 8 | """This rule represents a set of holidays. 9 | Args: 10 | holidays: a list with dates, or an object from the Holidays python module. 11 | """ 12 | kwargs['time_off'] = kwargs.get('time_off', True) 13 | self.holidays = holidays 14 | super(HolidayRule, self).__init__(*args, **kwargs) 15 | 16 | def __repr__(self): 17 | return '' % (self.holidays) 18 | 19 | def next_holiday(self, date, reverse=False, max_days=365 * 5): 20 | """ Get the next holiday 21 | Args: 22 | date: Find the next holiday after (or at) this date. 23 | max_days: Allowing both a list of dates as well as an object defined by 24 | the Holidays module requires a loop to test the holidays object against 25 | individual dates. To avoid getting stuck in an infinite loop here we need 26 | to give an upper limit of days to look into the future.""" 27 | 28 | count = 0 29 | while True: 30 | if date in self.holidays: 31 | return date 32 | 33 | if reverse: 34 | date -= datetime.timedelta(days=1) 35 | else: 36 | date += datetime.timedelta(days=1) 37 | 38 | count += 1 39 | if count > max_days: 40 | return None 41 | 42 | def next(self, dt, reverse=False): 43 | """Get the start and end of the next holiday after a datetime 44 | Args: 45 | dt: datetime 46 | """ 47 | dt = localize_unlocalized_dt(dt) 48 | localized_dt = dt.astimezone(self.tz) 49 | next_holiday = self.next_holiday(localized_dt.date(), reverse=reverse) 50 | start = self.tz.localize( 51 | datetime.datetime.combine( 52 | next_holiday, datetime.time(0, 0, 0))) 53 | end = start + datetime.timedelta(days=1) 54 | 55 | # If we are in the range now, set the start or end date to now. 56 | if start < dt and end > dt: 57 | if reverse: 58 | end = dt 59 | else: 60 | start = dt 61 | 62 | return (start, end) 63 | 64 | def previous(self, *args, **kwargs): 65 | """Reverse of next function 66 | """ 67 | kwargs['reverse'] = True 68 | return self.next(*args, **kwargs) 69 | -------------------------------------------------------------------------------- /businesstimedelta/test/rules/rules_tests.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import unittest 3 | import pytz 4 | from ...rules.rules import Rules 5 | from ...rules.workdayrules import WorkDayRule, LunchTimeRule 6 | 7 | 8 | class RulesTest(unittest.TestCase): 9 | def setUp(self): 10 | self.utc = pytz.timezone('UTC') 11 | self.workdayrule = WorkDayRule( 12 | start_time=datetime.time(9), 13 | end_time=datetime.time(17), 14 | working_days=[0, 1, 2, 3, 4], 15 | tz=self.utc) 16 | self.lunchbreak = LunchTimeRule( 17 | start_time=datetime.time(12), 18 | end_time=datetime.time(13), 19 | working_days=[0, 1, 2, 3, 4], 20 | tz=self.utc) 21 | self.rules = Rules([ 22 | self.workdayrule, 23 | self.lunchbreak]) 24 | 25 | def test_next_during_lunch_break(self): 26 | dt = self.utc.localize(datetime.datetime(2016, 1, 25, 12, 30, 0)) 27 | 28 | self.assertEqual( 29 | self.rules.next(dt), 30 | ( 31 | self.utc.localize(datetime.datetime(2016, 1, 25, 13, 0, 0)), 32 | self.utc.localize(datetime.datetime(2016, 1, 25, 17, 0, 0)) 33 | ) 34 | ) 35 | 36 | def test_next_before_lunch_break(self): 37 | dt = self.utc.localize(datetime.datetime(2016, 1, 25, 12, 0, 0)) 38 | 39 | self.assertEqual( 40 | self.rules.next(dt), 41 | ( 42 | self.utc.localize(datetime.datetime(2016, 1, 25, 13, 0, 0)), 43 | self.utc.localize(datetime.datetime(2016, 1, 25, 17, 0, 0)) 44 | ) 45 | ) 46 | 47 | def test_previous_during_lunch_break(self): 48 | dt = self.utc.localize(datetime.datetime(2016, 1, 25, 12, 30, 0)) 49 | 50 | self.assertEqual( 51 | self.rules.previous(dt), 52 | ( 53 | self.utc.localize(datetime.datetime(2016, 1, 25, 9, 0, 0)), 54 | self.utc.localize(datetime.datetime(2016, 1, 25, 12, 0, 0)) 55 | ) 56 | ) 57 | 58 | def test_previous_at_end_of_lunch_break(self): 59 | dt = self.utc.localize(datetime.datetime(2016, 1, 25, 13, 0, 0)) 60 | 61 | self.assertEqual( 62 | self.rules.previous(dt), 63 | ( 64 | self.utc.localize(datetime.datetime(2016, 1, 25, 9, 0, 0)), 65 | self.utc.localize(datetime.datetime(2016, 1, 25, 12, 0, 0)) 66 | ) 67 | ) 68 | 69 | def test_previous_after_lunch_break(self): 70 | dt = self.utc.localize(datetime.datetime(2016, 1, 25, 13, 30, 0)) 71 | 72 | self.assertEqual( 73 | self.rules.previous(dt), 74 | ( 75 | self.utc.localize(datetime.datetime(2016, 1, 25, 13, 0, 0)), 76 | self.utc.localize(datetime.datetime(2016, 1, 25, 13, 30, 0)) 77 | ) 78 | ) 79 | -------------------------------------------------------------------------------- /businesstimedelta/businesstimedelta.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import pytz 3 | 4 | 5 | def localize_unlocalized_dt(dt): 6 | """Turn naive datetime objects into UTC. 7 | Don't do anything if the datetime object is aware. 8 | https://docs.python.org/3/library/datetime.html#datetime.timezone 9 | """ 10 | if dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None: 11 | return dt 12 | return pytz.utc.localize(dt) 13 | 14 | 15 | class BusinessTimeDelta(object): 16 | def __init__(self, rule, hours=0, seconds=0, timedelta=None): 17 | self.rule = rule 18 | 19 | if timedelta: 20 | self.timedelta = timedelta 21 | else: 22 | self.timedelta = datetime.timedelta( 23 | seconds=seconds, 24 | hours=hours) 25 | 26 | def __repr__(self): 27 | return '' % (self.hours, self.seconds) 28 | 29 | def __eq__(self, other): 30 | return self.timedelta == other.timedelta 31 | 32 | def __add__(self, other): 33 | if isinstance(other, BusinessTimeDelta) and other.rule == self.rule: 34 | return BusinessTimeDelta(self.rule, timedelta=self.timedelta + other.timedelta) 35 | 36 | elif isinstance(other, datetime.datetime): 37 | dt = localize_unlocalized_dt(other) 38 | td_left = self.timedelta 39 | while True: 40 | period_start, period_end = self.rule.next(dt) 41 | period_delta = period_end - period_start 42 | 43 | # If we ran out of timedelta, return 44 | if period_delta >= td_left: 45 | return period_start + td_left 46 | 47 | td_left -= period_delta 48 | dt = period_end 49 | 50 | raise NotImplementedError 51 | 52 | def __radd__(self, other): 53 | return self.__add__(other) 54 | 55 | def __sub__(self, other): 56 | if isinstance(other, BusinessTimeDelta) and other.rule == self.rule: 57 | return BusinessTimeDelta(self.rule, timedelta=self.timedelta - other.timedelta) 58 | 59 | elif isinstance(other, datetime.datetime): 60 | dt = localize_unlocalized_dt(other) 61 | td_left = self.timedelta 62 | while True: 63 | period_start, period_end = self.rule.previous(dt) 64 | period_delta = period_end - period_start 65 | 66 | # If we ran out of timedelta, return 67 | if period_delta >= td_left: 68 | return period_end - td_left 69 | 70 | td_left -= period_delta 71 | dt = period_start 72 | 73 | def __rsub__(self, other): 74 | return self.__sub__(other) 75 | 76 | @property 77 | def hours(self): 78 | return int(self.timedelta.total_seconds() // (60 * 60)) 79 | 80 | @property 81 | def seconds(self): 82 | return int(self.timedelta.total_seconds() % (60 * 60)) 83 | -------------------------------------------------------------------------------- /businesstimedelta/test/rules/rule_tests.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import unittest 3 | import pytz 4 | from ...rules.workdayrules import WorkDayRule 5 | from ...businesstimedelta import BusinessTimeDelta 6 | 7 | 8 | class RuleTest(unittest.TestCase): 9 | def setUp(self): 10 | self.pst = pytz.timezone('US/Pacific') 11 | self.utc = pytz.timezone('UTC') 12 | self.workdayrule = WorkDayRule( 13 | start_time=datetime.time(9), 14 | end_time=datetime.time(17), 15 | working_days=[0, 1, 2, 3, 4], 16 | tz=self.utc) 17 | 18 | def test_difference_less_than_one_period(self): 19 | start_dt = self.utc.localize(datetime.datetime(2016, 1, 21, 10, 0, 0)) 20 | end_dt = self.utc.localize(datetime.datetime(2016, 1, 21, 10, 0, 5)) 21 | 22 | self.assertEqual( 23 | self.workdayrule.difference(start_dt, end_dt), 24 | BusinessTimeDelta(self.workdayrule, seconds=5) 25 | ) 26 | 27 | def test_difference_more_than_one_period(self): 28 | start_dt = self.utc.localize(datetime.datetime(2016, 1, 20, 10, 0, 0)) 29 | end_dt = self.utc.localize(datetime.datetime(2016, 1, 21, 10, 0, 5)) 30 | 31 | self.assertEqual( 32 | self.workdayrule.difference(start_dt, end_dt), 33 | BusinessTimeDelta(self.workdayrule, hours=8, seconds=5) 34 | ) 35 | 36 | def test_difference_several_days_period(self): 37 | start_dt = self.utc.localize(datetime.datetime(2016, 1, 18, 2, 0, 0)) 38 | end_dt = self.utc.localize(datetime.datetime(2016, 1, 24, 0, 0, 0)) 39 | 40 | self.assertEqual( 41 | self.workdayrule.difference(start_dt, end_dt), 42 | BusinessTimeDelta(self.workdayrule, hours=8 * 5) 43 | ) 44 | 45 | 46 | class AdversarialRuleTest(unittest.TestCase): 47 | def test_no_workday_hours(self): 48 | self.workdayrule = WorkDayRule( 49 | start_time=datetime.time(9), 50 | end_time=datetime.time(9), 51 | working_days=[0, 1, 2, 3, 4]) 52 | 53 | start_dt = datetime.datetime(2016, 1, 21, 10, 0, 0) 54 | end_dt = datetime.datetime(2016, 1, 21, 10, 0, 5) 55 | 56 | self.assertEqual( 57 | self.workdayrule.difference(start_dt, end_dt), 58 | BusinessTimeDelta(self.workdayrule, seconds=0, hours=0) 59 | ) 60 | 61 | 62 | class NightShiftRuleTest(unittest.TestCase): 63 | def setUp(self): 64 | self.utc = pytz.timezone('UTC') 65 | self.workdayrule = WorkDayRule( 66 | start_time=datetime.time(23), 67 | end_time=datetime.time(1), 68 | working_days=[0, 1, 2, 3, 4], 69 | tz=self.utc) 70 | 71 | def test_difference_overnight(self): 72 | start_dt = self.utc.localize(datetime.datetime(2016, 1, 21, 10, 0, 0)) 73 | end_dt = self.utc.localize(datetime.datetime(2016, 1, 22, 10, 0, 0)) 74 | 75 | self.assertEqual( 76 | BusinessTimeDelta(self.workdayrule, hours=2), 77 | self.workdayrule.difference(start_dt, end_dt) 78 | ) 79 | -------------------------------------------------------------------------------- /businesstimedelta/rules/rules.py: -------------------------------------------------------------------------------- 1 | from .rule import Rule 2 | from ..businesstimedelta import localize_unlocalized_dt 3 | 4 | 5 | class Rules(Rule): 6 | """Combine a list of rules together to form one rule. 7 | Args: 8 | rules: a list of rule objects. 9 | """ 10 | def __init__(self, rules, *args, **kwargs): 11 | self.available_rules = [x for x in rules if not x.time_off] 12 | self.unavailable_rules = [x for x in rules if x.time_off] 13 | super(Rules, self).__init__(*args, **kwargs) 14 | 15 | def next(self, dt): 16 | dt = localize_unlocalized_dt(dt) 17 | min_start = None 18 | min_end = None 19 | 20 | while True: 21 | # Find the first upcoming available time 22 | for rule in self.available_rules: 23 | start, end = rule.next(dt) 24 | 25 | if not min_start or start < min_start: 26 | min_start = start 27 | min_end = end 28 | 29 | # Check whether that time is not unavailable due to an 30 | # unavailability rule. If so, restart this process beginning 31 | # at the end of this unavailability period. 32 | for rule in self.unavailable_rules: 33 | start, end = rule.next(min_start) 34 | 35 | if start == min_start: 36 | dt = end 37 | min_start = None 38 | break 39 | 40 | # We found the first time that is available. 41 | # Now see when it becomes unavailable. 42 | if min_start: 43 | for rule in self.unavailable_rules: 44 | start, end = rule.next(min_start) 45 | 46 | if end < min_end: 47 | min_end = start 48 | 49 | if min_end != min_start: 50 | return (min_start, min_end) 51 | 52 | def previous(self, dt): 53 | dt = localize_unlocalized_dt(dt) 54 | min_start = None 55 | min_end = None 56 | 57 | while True: 58 | # Find the first available time in the past 59 | for rule in self.available_rules: 60 | start, end = rule.previous(dt) 61 | 62 | if not min_end or end > min_end: 63 | min_start = start 64 | min_end = end 65 | 66 | # Check whether that time is not unavailable due to an 67 | # unavailability rule. If so, restart this process beginning 68 | # at the start of this unavailability period. 69 | for rule in self.unavailable_rules: 70 | start, end = rule.previous(min_end) 71 | 72 | if end == min_end: 73 | dt = start 74 | min_end = None 75 | break 76 | 77 | # We found the first time that is available. 78 | # Now see when it becomes unavailable. 79 | if min_end: 80 | for rule in self.unavailable_rules: 81 | start, end = rule.previous(min_end) 82 | if end > min_start: 83 | min_start = end 84 | 85 | if min_end != min_start: 86 | return (min_start, min_end) 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BusinessTimeDelta 2 | Python's timedelta for business time. This module helps you calculate the exact working time between two datetimes. It supports common scenarios such as custom schedules, holidays, and time zones. 3 | 4 | [![Build Status](https://travis-ci.org/seppemans/businesstimedelta.svg?branch=master)](https://travis-ci.org/seppemans/businesstimedelta) 5 | 6 | ## Installation 7 | Use pip to install BusinessTimeDelta. 8 | 9 | ```shell 10 | pip install businesstimedelta 11 | ``` 12 | 13 | ## Example Use 14 | Define your business hours 15 | 16 | ```python 17 | import datetime 18 | import pytz 19 | import businesstimedelta 20 | 21 | # Define a working day 22 | workday = businesstimedelta.WorkDayRule( 23 | start_time=datetime.time(9), 24 | end_time=datetime.time(18), 25 | working_days=[0, 1, 2, 3, 4]) 26 | 27 | # Take out the lunch break 28 | lunchbreak = businesstimedelta.LunchTimeRule( 29 | start_time=datetime.time(12), 30 | end_time=datetime.time(13), 31 | working_days=[0, 1, 2, 3, 4]) 32 | 33 | # Combine the two 34 | businesshrs = businesstimedelta.Rules([workday, lunchbreak]) 35 | ``` 36 | 37 | Calculate the business time between two datetimes 38 | 39 | ```python 40 | start = datetime.datetime(2016, 1, 18, 9, 0, 0) 41 | end = datetime.datetime(2016, 1, 22, 18, 0, 0) 42 | bdiff = businesshrs.difference(start, end) 43 | 44 | print bdiff 45 | # 46 | 47 | print "%s hours and %s seconds" % (bdiff.hours, bdiff.seconds) 48 | # 40 hours and 0 seconds 49 | ``` 50 | 51 | Business time arithmetic 52 | 53 | ```python 54 | print start + businesstimedelta.BusinessTimeDelta(businesshrs, hours=40) 55 | # 2016-01-22 18:00:00+00:00 56 | 57 | print end - businesstimedelta.BusinessTimeDelta(businesshrs, hours=40) 58 | # 2016-01-18 09:00:00+00:00 59 | ``` 60 | 61 | To define holidays, simply use the [Holidays](https://pypi.python.org/pypi/holidays) package 62 | 63 | ```python 64 | import holidays as pyholidays 65 | 66 | ca_holidays = pyholidays.US(state='CA') 67 | holidays = businesstimedelta.HolidayRule(ca_holidays) 68 | businesshrs = businesstimedelta.Rules([workday, lunchbreak, holidays]) 69 | 70 | # Christmas is on Friday 2015/12/25 71 | start = datetime.datetime(2015, 12, 21, 9, 0, 0) 72 | end = datetime.datetime(2015, 12, 28, 9, 0, 0) 73 | print businesshrs.difference(start, end) 74 | # 75 | ``` 76 | 77 | ## Timezones 78 | If your datetimes are not timezone aware, they will be localized to UTC (see example above). 79 | 80 | Let's say you want to calculate the business time overlap between a working day in San Francisco and in Santiago, Chile: 81 | ```python 82 | santiago_workday = businesstimedelta.WorkDayRule( 83 | start_time=datetime.time(9), 84 | end_time=datetime.time(18), 85 | working_days=[0, 1, 2, 3, 4], 86 | tz=pytz.timezone('America/Santiago')) 87 | 88 | santiago_lunchbreak = businesstimedelta.LunchTimeRule( 89 | start_time=datetime.time(12), 90 | end_time=datetime.time(13), 91 | working_days=[0, 1, 2, 3, 4], 92 | tz=pytz.timezone('America/Santiago')) 93 | 94 | santiago_businesshrs = businesstimedelta.Rules([santiago_workday, santiago_lunchbreak]) 95 | 96 | sf_tz = pytz.timezone('America/Los_Angeles') 97 | sf_start = sf_tz.localize(datetime.datetime(2016, 1, 18, 9, 0, 0)) 98 | sf_end = sf_tz.localize(datetime.datetime(2016, 1, 18, 18, 0, 0)) 99 | 100 | print santiago_businesshrs.difference(sf_start, sf_end) 101 | # 102 | ``` 103 | 104 | ## Overnight Shifts 105 | ```python 106 | # Day shift 107 | workday = WorkDayRule( 108 | start_time=datetime.time(9), 109 | end_time=datetime.time(17), 110 | working_days=[0, 1, 2, 3, 4], 111 | tz=pytz.utc) 112 | 113 | # Night shift 114 | nightshift = businesstimedelta.WorkDayRule( 115 | start_time=datetime.time(23), 116 | end_time=datetime.time(7), 117 | working_days=[0, 1, 2, 3, 4]) 118 | 119 | businesshrs = businesstimedelta.Rules([workday, nightshift]) 120 | 121 | start = datetime.datetime(2016, 1, 18, 9, 0, 0) 122 | end = datetime.datetime(2016, 1, 22, 18, 0, 0) 123 | bdiff = businesshrs.difference(start, end) 124 | 125 | print bdiff 126 | # 127 | ``` 128 | -------------------------------------------------------------------------------- /businesstimedelta/rules/workdayrules.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from .rule import Rule 3 | from ..businesstimedelta import localize_unlocalized_dt 4 | 5 | 6 | class WorkDayRule(Rule): 7 | """Basic implementation of a working day that starts and ends some days of 8 | the week at the same start and end time. Ex. Monday through Friday in the EST timezone. 9 | 10 | Overnight shifts can be represented by setting end time less than start time. 11 | """ 12 | 13 | def __init__(self, start_time=datetime.time(9), end_time=datetime.time(18), 14 | working_days=[0, 1, 2, 3, 4], *args, **kwargs): 15 | """ 16 | Args: 17 | start_time: a Time object that defines the start of a work day 18 | end_time: a Time object that defines the end of a work day 19 | working_days: days of the working week (0 = Monday) 20 | tz: a pytz timezone 21 | """ 22 | kwargs['time_off'] = kwargs.get('time_off', False) 23 | super(WorkDayRule, self).__init__(*args, **kwargs) 24 | self.start_time = start_time 25 | self.end_time = end_time 26 | self.working_days = working_days 27 | 28 | def next(self, dt, reverse=False): 29 | dt = localize_unlocalized_dt(dt) 30 | localized_dt = dt.astimezone(self.tz) 31 | 32 | # Figure out what the first upcoming working date is 33 | working_date = localized_dt.date() 34 | 35 | if working_date.weekday() in self.working_days and localized_dt.time() < max(self.start_time, self.end_time): 36 | # Today is the working day to use in further calculations if there is 37 | # any working time left in this day. Ie, if the current time is 38 | # - less than the end time (for normal cases) 39 | # - less than the start time (for overnight work days) 40 | pass 41 | else: 42 | while True: 43 | working_date += datetime.timedelta(days=1) 44 | if working_date.weekday() in self.working_days: 45 | break 46 | 47 | # We know the target working date now. Just figure out the start and end times. 48 | start = self.tz.localize(datetime.datetime.combine(working_date, self.start_time)) 49 | 50 | # In the case this working day has some overnight time, add one day to the end date 51 | if self.end_time < self.start_time: 52 | working_date += datetime.timedelta(days=1) 53 | 54 | end = self.tz.localize(datetime.datetime.combine(working_date, self.end_time)) 55 | 56 | # If we are in the range now, set the start or end date to now. 57 | if start < dt and end > dt: 58 | start = dt 59 | 60 | return (start, end) 61 | 62 | def previous(self, dt, *args, **kwargs): 63 | dt = localize_unlocalized_dt(dt) 64 | localized_dt = dt.astimezone(self.tz) 65 | 66 | # Figure out what is the first upcoming working date 67 | working_date = localized_dt.date() 68 | if working_date.weekday() in self.working_days \ 69 | and localized_dt.time() > self.start_time: 70 | pass # Today is the working date 71 | else: 72 | while True: 73 | working_date -= datetime.timedelta(days=1) 74 | if working_date.weekday() in self.working_days: 75 | break 76 | 77 | # We know the target working date now. Just figure out the start and end times. 78 | start = self.tz.localize(datetime.datetime.combine(working_date, self.start_time)) 79 | end = self.tz.localize(datetime.datetime.combine(working_date, self.end_time)) 80 | 81 | # If we are in the range now, set the start or end date to now. 82 | if start < dt and end > dt: 83 | end = dt 84 | 85 | return (start, end) 86 | 87 | 88 | class LunchTimeRule(WorkDayRule): 89 | """Convenience function for lunch breaks.""" 90 | def __init__(self, start_time=datetime.time(12), end_time=datetime.time(13), 91 | working_days=[0, 1, 2, 3, 4], *args, **kwargs): 92 | super(LunchTimeRule, self).__init__( 93 | start_time=start_time, 94 | end_time=end_time, 95 | working_days=working_days, 96 | time_off=True, 97 | *args, **kwargs) 98 | -------------------------------------------------------------------------------- /businesstimedelta/test/rules/workdayrule_tests.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import unittest 3 | import pytz 4 | from ...rules.workdayrules import WorkDayRule, LunchTimeRule 5 | 6 | 7 | class WorkDayRuleTest(unittest.TestCase): 8 | def setUp(self): 9 | self.pst = pytz.timezone('US/Pacific') 10 | self.utc = pytz.timezone('UTC') 11 | self.workdayrule = WorkDayRule( 12 | start_time=datetime.time(9), 13 | end_time=datetime.time(17), 14 | working_days=[0, 1, 2, 3, 4], 15 | tz=self.utc) 16 | 17 | def test_next_during_weekend(self): 18 | dt = self.utc.localize(datetime.datetime(2016, 1, 23, 13, 14, 0)) 19 | 20 | self.assertEqual( 21 | self.workdayrule.next(dt), 22 | ( 23 | self.utc.localize(datetime.datetime(2016, 1, 25, 9, 0, 0)), 24 | self.utc.localize(datetime.datetime(2016, 1, 25, 17, 0, 0)) 25 | ) 26 | ) 27 | 28 | def test_next_during_working_day(self): 29 | dt = self.utc.localize(datetime.datetime(2016, 1, 25, 10, 0, 0)) 30 | 31 | self.assertEqual( 32 | self.workdayrule.next(dt), 33 | ( 34 | dt, 35 | self.utc.localize(datetime.datetime(2016, 1, 25, 17, 0, 0)) 36 | ) 37 | ) 38 | 39 | def test_next_before_working_day(self): 40 | dt = self.utc.localize(datetime.datetime(2016, 1, 25, 3, 0, 0)) 41 | 42 | self.assertEqual( 43 | self.workdayrule.next(dt), 44 | ( 45 | self.utc.localize(datetime.datetime(2016, 1, 25, 9, 0, 0)), 46 | self.utc.localize(datetime.datetime(2016, 1, 25, 17, 0, 0)) 47 | ) 48 | ) 49 | 50 | def test_previous_during_working_day(self): 51 | dt = self.utc.localize(datetime.datetime(2016, 1, 25, 10, 0, 0)) 52 | 53 | self.assertEqual( 54 | self.workdayrule.previous(dt), 55 | ( 56 | self.utc.localize(datetime.datetime(2016, 1, 25, 9, 0, 0)), 57 | dt 58 | ) 59 | ) 60 | 61 | def test_previous_during_weekend(self): 62 | dt = self.utc.localize(datetime.datetime(2016, 1, 23, 13, 14, 0)) 63 | 64 | self.assertEqual( 65 | self.workdayrule.previous(dt), 66 | ( 67 | self.utc.localize(datetime.datetime(2016, 1, 22, 9, 0, 0)), 68 | self.utc.localize(datetime.datetime(2016, 1, 22, 17, 0, 0)), 69 | ) 70 | ) 71 | 72 | def test_previous_at_working_day_start(self): 73 | dt = self.utc.localize(datetime.datetime(2016, 1, 25, 9, 00, 0)) 74 | 75 | self.assertEqual( 76 | self.workdayrule.previous(dt), 77 | ( 78 | self.utc.localize(datetime.datetime(2016, 1, 22, 9, 0, 0)), 79 | self.utc.localize(datetime.datetime(2016, 1, 22, 17, 0, 0)), 80 | ) 81 | ) 82 | 83 | def test_previous_at_working_day_end(self): 84 | dt = self.utc.localize(datetime.datetime(2016, 1, 25, 17, 00, 0)) 85 | 86 | self.assertEqual( 87 | self.workdayrule.previous(dt), 88 | ( 89 | self.utc.localize(datetime.datetime(2016, 1, 25, 9, 0, 0)), 90 | self.utc.localize(datetime.datetime(2016, 1, 25, 17, 0, 0)), 91 | ) 92 | ) 93 | 94 | def test_previous_after_working_day_end(self): 95 | dt = self.utc.localize(datetime.datetime(2016, 1, 25, 18, 00, 0)) 96 | 97 | self.assertEqual( 98 | self.workdayrule.previous(dt), 99 | ( 100 | self.utc.localize(datetime.datetime(2016, 1, 25, 9, 0, 0)), 101 | self.utc.localize(datetime.datetime(2016, 1, 25, 17, 0, 0)), 102 | ) 103 | ) 104 | 105 | 106 | class LunchTimeRuleTest(unittest.TestCase): 107 | def setUp(self): 108 | self.utc = pytz.timezone('UTC') 109 | self.lunchtimerule = LunchTimeRule( 110 | start_time=datetime.time(12), 111 | end_time=datetime.time(13), 112 | working_days=[0, 1, 2, 3, 4], 113 | tz=self.utc) 114 | 115 | def test_next_during_lunch_time(self): 116 | dt = self.utc.localize(datetime.datetime(2016, 1, 25, 12, 30, 0)) 117 | 118 | self.assertEqual( 119 | self.lunchtimerule.next(dt), 120 | ( 121 | dt, 122 | self.utc.localize(datetime.datetime(2016, 1, 25, 13, 0, 0)) 123 | ) 124 | ) 125 | 126 | def test_lunch_time_off(self): 127 | self.assertEqual(self.lunchtimerule.time_off, True) 128 | -------------------------------------------------------------------------------- /businesstimedelta/test/rules/holidayrule_tests.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import unittest 3 | import pytz 4 | import holidays as holidaymodule 5 | from ...rules.holidayrules import HolidayRule 6 | 7 | 8 | class HolidayRuleTest(unittest.TestCase): 9 | def setUp(self): 10 | self.pst = pytz.timezone('US/Pacific') 11 | self.utc = pytz.timezone('UTC') 12 | self.holidays = [ 13 | datetime.date(2015, 12, 25), 14 | datetime.date(2016, 12, 25), 15 | datetime.date(2017, 12, 25) 16 | ] 17 | 18 | def test_repr(self): 19 | holiday = HolidayRule(self.holidays) 20 | 21 | self.assertEqual( 22 | str(holiday)[:12], 23 | "