├── .gitignore ├── AUTHORS ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── setup.py ├── test.sh ├── tests └── test.py └── timespan ├── __init__.py ├── asterisk.py └── dotnet.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | /build 4 | /dist 5 | *.egg-info 6 | 7 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Justine Alexndra Roberts Tunney 2 | Microsoft Corporation 3 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Python's timespan module is licensed under the MIT License. Every source 2 | file documents the notices which apply to that file. The full list of 3 | licenses that exist in this repository has also been included below. 4 | 5 | --- 6 | 7 | Copyright 2023 Justine Alexandra Roberts Tunney 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy 10 | of this software and associated documentation files (the "Software"), to deal 11 | in the Software without restriction, including without limitation the rights 12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the Software is 14 | furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in all 17 | copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | SOFTWARE 26 | 27 | --- 28 | 29 | Copyright (c) Microsoft Corporation. 30 | 31 | Permission is hereby granted, free of charge, to any person obtaining a copy 32 | of this software and associated documentation files (the "Software"), to deal 33 | in the Software without restriction, including without limitation the rights 34 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 35 | copies of the Software, and to permit persons to whom the Software is 36 | furnished to do so, subject to the following conditions: 37 | 38 | The above copyright notice and this permission notice shall be included in all 39 | copies or substantial portions of the Software. 40 | 41 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 42 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 43 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 44 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 45 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 46 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 47 | SOFTWARE 48 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE.txt 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # timespan 2 | 3 | Utilities for working with popular timespan formats. 4 | 5 | ## .Net Timespans 6 | 7 | Functions are provided for converting between .NET 7.0 TimeSpan objects 8 | (with format specifiers) and Python's `datetime.timedelta` objects. 9 | 10 | ### `timespan.from_string(str) -> timedelta` 11 | 12 | Converts a TimeSpan string in the current locale to a 13 | `datetime.timedelta` object, e.g. 14 | 15 | >>> import timespan 16 | >>> timespan.c(3, 17, 25, 30, 500) 17 | '3.17:25:30.5000000' 18 | >>> timespan.from_string('3.17:25:30.5000000') 19 | datetime.timedelta(days=3, seconds=62730, microseconds=500000) 20 | 21 | #### Parameters 22 | 23 | - `timespan_string`: TimeSpan string of any format and locale. 24 | 25 | #### Return Value 26 | 27 | A timedelta object. 28 | 29 | ### `timespan.to_string(specifier: str, *args: tuple) -> str` 30 | 31 | Converts date\time information (variable-length tuple) to a TimeSpan 32 | string in the current locale. 33 | 34 | #### Parameters 35 | 36 | - `specifier`: format specifier. Options are 'c', 'g' and 'G'. 37 | - `args`: variable-length tuple (size 1, 3, 4 or 5) specifying 38 | components of date and time. 39 | 40 | #### Return Value 41 | 42 | A TimeSpan string 43 | 44 | ### Notes 45 | 46 | See the switch case of `_args_to_seconds()` in 47 | [src/timespan/dotnet.py](src/timespan/dotnet.py) for full coverage of 48 | all input types. 49 | 50 | ## Asterisk Timespans 51 | 52 | Asterisk style timespans allow you to check if a timestamp falls within 53 | a specified list of boundaries. For example, you might want to program 54 | your phone system to only accept calls Mon-Fri from 9 a.m. to 5 p.m. 55 | except on holidays like Christmas. 56 | 57 | Timespans are specified in the form of `times|daysofweek|days|months`. 58 | If your timespan starts with `!`, it'll only match if the timestamps 59 | falls outside the given range. 60 | 61 | Basic example: 62 | 63 | import timespan 64 | from datetime import datetime 65 | 66 | business_hours = [ 67 | '9:00-17:00|mon-fri|*|*', # is between 9 a.m. to 5 p.m. on Mon to Fri 68 | '!*|*|1|jan', # not new years 69 | '!*|*|25|dec', # not christmas 70 | '!*|thu|22-28|nov', # not thanksgiving 71 | ] 72 | 73 | if timespan.match(business_hours, datetime.now()): 74 | print("we're open for business!") 75 | else: 76 | print("sorry, we're closed :(") 77 | 78 | For more examples, see the documentation or source code. 79 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | 4 | def read(fname): 5 | return open(os.path.join(os.path.dirname(__file__), fname)).read() 6 | 7 | setup( 8 | name = "timespan", 9 | version = '0.2.0', 10 | description = "Utilities for Asterisk and Microsoft .Net timespans", 11 | long_description = read("README.md"), 12 | long_description_content_type = 'text/markdown', 13 | author = "Justine Tunney", 14 | author_email = "jtunney@gmail.com", 15 | url = "https://github.com/jart/timespan", 16 | license = "MIT", 17 | python_requires = ">=2.7", 18 | install_requires = [], 19 | packages = find_packages(include=['timespan', 'timespan.*']), 20 | classifiers = [ 21 | "Development Status :: 5 - Production/Stable", 22 | "License :: OSI Approved :: MIT License", 23 | "Intended Audience :: Developers", 24 | "Programming Language :: Python", 25 | ], 26 | ) 27 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -ex 3 | python3 setup.py install --prefix ~/.local 4 | python3 timespan/asterisk.py 5 | python3 tests/test.py 6 | -------------------------------------------------------------------------------- /tests/test.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining 4 | # a copy of this software and associated documentation files (the 5 | # "Software"), to deal in the Software without restriction, including 6 | # without limitation the rights to use, copy, modify, merge, publish, 7 | # distribute, sublicense, and/or sell copies of the Software, and to 8 | # permit persons to whom the Software is furnished to do so, subject to 9 | # the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be 12 | # included in all copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE 21 | 22 | import doctest 23 | import math 24 | import tqdm 25 | import timespan 26 | 27 | MAX_TEST_VAL = 1000000 # maximal value to test. its additive inverse number is the minimal value to test 28 | TEST_STEP = 0.987654321 # difference between consecutive test values. should be a number slightly less than 1.0 29 | TEST_PRECISION = 1e-6 # sufferable absolute difference of the required result when converting 30 | 31 | 32 | def _test(start, stop, step): 33 | """ 34 | Iterative test of converting float values to TimeSpan and back to float, verifying the value is kept 35 | """ 36 | 37 | num_vals = int((stop - start) / step) 38 | stop = start + (num_vals + 1) * step 39 | test_values = (start + i * step for i in range(num_vals)) 40 | 41 | print( 42 | f"Testing {num_vals} values in range", 43 | f"{timespan.g(start)} : {timespan.g(stop)}", 44 | f"with specifiers {{c,g,G}}", 45 | ) 46 | for test_time in tqdm.tqdm(test_values, total=num_vals): 47 | for timespan_ctor in (timespan.c, timespan.g, timespan.G): 48 | string_from_float = timespan_ctor(test_time) 49 | float_from_string = timespan.from_string(string_from_float).total_seconds() 50 | assert math.isclose(test_time, float_from_string, abs_tol=TEST_PRECISION),\ 51 | (test_time, float_from_string, timespan_ctor.func.__name__) 52 | print("Test passed!") 53 | 54 | 55 | if __name__ == "__main__": 56 | doctest.testmod(timespan, verbose=True) 57 | _test(start=-MAX_TEST_VAL, stop=MAX_TEST_VAL, step=TEST_STEP) # guarantees at least 2*MAX_TEST_VAL values were tested 58 | -------------------------------------------------------------------------------- /timespan/__init__.py: -------------------------------------------------------------------------------- 1 | # import api inspired by the asterisk phone system, which lets you 2 | # define timespans in the 'business hours' sense, using strings to 3 | # describe your time intervals, and then check if a datetime falls 4 | # within those intervals. 5 | from timespan.asterisk import match, match_one 6 | 7 | # import api inspired by microsoft .net which lets you define time 8 | # deltas using a popular string syntax. 9 | from timespan.dotnet import to_string, from_string, total_seconds, \ 10 | c, constant, g, general_short, G, general_long 11 | -------------------------------------------------------------------------------- /timespan/asterisk.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # timespan - Check if timestamps fall within specific boundaries 4 | # Copyright 2023 Justine Alexandra Roberts Tunney 5 | # 6 | # Permission is hereby granted, free of charge, to any person 7 | # obtaining a copy of this software and associated documentation 8 | # files (the "Software"), to deal in the Software without 9 | # restriction, including without limitation the rights to use, copy, 10 | # modify, merge, publish, distribute, sublicense, and/or sell copies 11 | # of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be 15 | # included in all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 19 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 21 | # BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 22 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 23 | # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | # SOFTWARE. 25 | # 26 | r""" 27 | 28 | timespan 29 | ~~~~~~~~ 30 | 31 | Timespans allow you to check if a timestamp falls within a specified list 32 | of boundaries. For example, you might want to program your phone system 33 | to only accept calls Mon-Fri from 9 a.m. to 5 p.m. except on holidays like 34 | Christmas. 35 | 36 | Timespans are specified in the form of ``times|daysofweek|days|months``. 37 | If your timespan starts with ``!`` it'll match timestamps falling outside 38 | the specified range. 39 | 40 | Determine if timestamp is during 9 a.m. to 5 p.m. Monday through Friday 41 | business hours:: 42 | 43 | >>> from datetime import datetime 44 | >>> dt = datetime(2012, 3, 29, 12, 0) # Thursday @ Noon 45 | >>> match('9:00-17:00|mon-fri|*|*', dt) 46 | True 47 | >>> match('9:00-17:00|mon-fri', dt) 48 | True 49 | >>> match('9:00-17:00', dt) 50 | True 51 | 52 | Determine if within business hours, excluding Christmas:: 53 | 54 | >>> dt = datetime(2012, 12, 25, 12, 0) # X-Mas Tuesday @ Noon 55 | >>> match('9:00-17:00|mon-fri|*|*', dt) 56 | True 57 | >>> match(['9:00-17:00|mon-fri|*|*', '!*|*|25|dec'], dt) 58 | False 59 | >>> dt = datetime(2012, 12, 24, 12, 0) # X-Mas Eve Monday @ Noon 60 | >>> match(['9:00-17:00|mon-fri|*|*', '!*|*|25|dec'], dt) 61 | True 62 | 63 | Determine if within any of several timespans:: 64 | 65 | >>> dt = datetime(2012, 3, 29, 12, 0) # Thursday @ Noon 66 | >>> match(['9:00-11:00|mon-fri|*|*', '13:00-17:00|mon-fri|*|*'], dt, match_any=True) 67 | False 68 | >>> match(['9:00-13:00|mon-fri|*|*', '14:00-17:00|mon-fri|*|*'], dt, match_any=True) 69 | True 70 | >>> match(['9:00-10:00|mon-fri|*|*', '11:00-17:00|mon-fri|*|*'], dt, match_any=True) 71 | True 72 | 73 | Multiple timespans can be a list or newline delimited:: 74 | 75 | >>> dt = datetime(2012, 12, 25, 12, 0) # X-Mas Tuesday @ Noon 76 | >>> match('9:00-17:00|mon-fri|*|*\n!*|*|25|dec', dt) 77 | False 78 | 79 | More examples:: 80 | 81 | >>> thetime = datetime(2002, 12, 25, 22, 35) # X-Mas on Wednesday 82 | >>> match('09:00-18:00|mon-fri|*|*', thetime) 83 | False 84 | >>> match('09:00-16:00|sat-sat|*|*', thetime) 85 | False 86 | >>> match('*|*|1|jan', thetime) 87 | False 88 | >>> match('*|*|25|dec', thetime) 89 | True 90 | >>> match('23:00-02:00|wed|30-25|dec-jan', thetime) 91 | False 92 | >>> match('22:00-02:00|wed|30-25|dec-jan', thetime) 93 | True 94 | >>> thetime = datetime(2006, 9, 21, 12, 30) 95 | >>> match('09:00-18:00|mon-fri|*|*', thetime) 96 | True 97 | >>> match('09:00-16:00|sat-sat|*|*', thetime) 98 | False 99 | >>> match('*|*|1-1|jan-jan', thetime) 100 | False 101 | >>> match('*|*|25-25|dec-dec', thetime) 102 | False 103 | >>> match('23:00-02:00|wed|30-25|dec-jan', thetime) 104 | False 105 | 106 | >>> birth = datetime(1984, 12, 18, 6, 30) # tuesday 107 | >>> dows = ['mon', 'tue', 'wed', 'fri', 'sat', 'sun'] 108 | >>> [match('*|%s|*|*' % (s), birth) for s in dows] 109 | [False, True, False, False, False, False] 110 | >>> [match('*|%s-%s|*|*' % (s, s), birth) for s in dows] 111 | [False, True, False, False, False, False] 112 | >>> match('*|mon-wed|*|*', birth) 113 | True 114 | >>> match('*|mon-wed|*|*', birth) 115 | True 116 | >>> match('*|wed-mon|*|*', birth) 117 | False 118 | 119 | >>> bizhr = [ 120 | ... '9:00-17:00|mon-fri|*|*', 121 | ... '!*|*|1|jan', 122 | ... '!*|*|25|dec', 123 | ... '!*|thu|22-28|nov', 124 | ... ] 125 | >>> match(bizhr, datetime(2012, 12, 24, 12, 00)) 126 | True 127 | >>> match(bizhr, datetime(2012, 12, 24, 8, 00)) 128 | False 129 | >>> match(bizhr, datetime(2012, 12, 24, 20, 00)) 130 | False 131 | >>> match(bizhr, datetime(2012, 12, 25, 12, 00)) 132 | False 133 | >>> match(bizhr, datetime(2013, 1, 1, 12, 00)) 134 | False 135 | 136 | The BNF syntax for timespans is as follows:: 137 | 138 | x ::= [0-9] | [0-9] x 139 | 140 | time ::= x ':' x 141 | times ::= '*' | time | time '-' time 142 | 143 | dow ::= 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun' 144 | dows ::= '*' | dow | dow '-' dow 145 | 146 | days ::= '*' | x | x '-' x 147 | 148 | month ::= 'jan' | 'feb' | 'mar' | 'apr' | 'may' | 'jun' | 'jul' 149 | | 'aug' | 'sep' | 'oct' | 'nov' | 'dec' 150 | months ::= '*' | month | month '-' month 151 | 152 | timespan ::= times 153 | | times '|' dows 154 | | times '|' dows '|' days 155 | | times '|' dows '|' days '|' months 156 | 157 | timespan2 ::= timespan 158 | | '!' timespan 159 | 160 | timespans ::= timespan2 161 | | timespan2 '\n' timespans 162 | 163 | """ 164 | 165 | import sys 166 | from datetime import datetime, time 167 | 168 | __author__ = "Justine Tunney" 169 | __email__ = "jtunney@gmail.com" 170 | __version__ = '0.1' 171 | 172 | WEEKDAYS = {'mon': 0, 'tue': 1, 'wed': 2, 'thu': 3, 173 | 'fri': 4, 'sat': 5, 'sun': 6} 174 | MONTHS = {'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, 'may': 5, 175 | 'jun': 6, 'jul': 7, 'aug': 8, 'sep': 9, 'oct': 10, 176 | 'nov': 11, 'dec': 12} 177 | 178 | 179 | def match(timespans, dt=None, match_any=False): 180 | """Determine if timestamp falls within one or more timespans""" 181 | dt = dt or datetime.now() 182 | strtype = str if sys.version_info[0] >= 3 else basestring 183 | if isinstance(timespans, strtype): 184 | timespans = timespans.splitlines() 185 | timespans = [ts for ts in timespans if ts.strip()] 186 | if match_any: 187 | return any(match_one(timespan, dt) for timespan in timespans) 188 | else: 189 | return all(match_one(timespan, dt) for timespan in timespans) 190 | 191 | 192 | def match_one(timespan, dt=None): 193 | """Matches against only a single timespan""" 194 | timespan = timespan.strip() 195 | dt = dt or datetime.now() 196 | if timespan.startswith('!'): 197 | inverse = True 198 | timespan = timespan[1:] 199 | else: 200 | inverse = False 201 | ts = timespan.split('|') + ['*', '*', '*'] 202 | times, dows, days, months = ts[:4] 203 | if times != '*': 204 | lo, hi = _span(times, _parse_time) 205 | if not _inside(dt.time(), lo, hi): 206 | return inverse 207 | if dows != '*': 208 | lo, hi = _span(dows, _parse_weekday) 209 | if not _inside(dt.weekday(), lo, hi): 210 | return inverse 211 | if days != '*': 212 | lo, hi = _span(days, int) 213 | if not _inside(dt.day, lo, hi): 214 | return inverse 215 | if months != '*': 216 | lo, hi = _span(months, _parse_month) 217 | if not _inside(dt.month, lo, hi): 218 | return inverse 219 | return not inverse 220 | 221 | 222 | def _span(val, f): 223 | vals = [f(s) for s in val.split('-')] 224 | if len(vals) == 1: 225 | return vals[0], vals[0] 226 | else: 227 | lo, hi = vals 228 | return lo, hi 229 | 230 | 231 | def _inside(x, lo, hi): 232 | if hi == time(0): 233 | return lo <= x 234 | elif hi >= lo: 235 | return lo <= x <= hi 236 | else: 237 | return x >= lo or x <= hi 238 | 239 | 240 | def _parse_time(s): 241 | return datetime.strptime(s, '%H:%M').time() 242 | 243 | 244 | def _parse_weekday(s): 245 | if s in WEEKDAYS: 246 | return WEEKDAYS[s[:3].lower()] 247 | else: 248 | raise ValueError('bad weekday', s) 249 | 250 | 251 | def _parse_month(s): 252 | if s in MONTHS: 253 | return MONTHS[s[:3].lower()] 254 | else: 255 | raise ValueError('bad month', s) 256 | 257 | 258 | if __name__ == "__main__": 259 | import doctest 260 | doctest.testmod() 261 | -------------------------------------------------------------------------------- /timespan/dotnet.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining 4 | # a copy of this software and associated documentation files (the 5 | # "Software"), to deal in the Software without restriction, including 6 | # without limitation the rights to use, copy, modify, merge, publish, 7 | # distribute, sublicense, and/or sell copies of the Software, and to 8 | # permit persons to whom the Software is furnished to do so, subject to 9 | # the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be 12 | # included in all copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE 21 | 22 | import datetime 23 | import locale 24 | import os 25 | import re 26 | from functools import partial 27 | 28 | __author__ = "Mattan Serry" 29 | __email__ = "maserry@microsoft.com" 30 | __version__ = "0.1.2" 31 | 32 | """ 33 | 34 | Module to convert between .NET 7.0 TimeSpan objects (with format specifiers) and Python's datetime.timedelta objects. 35 | 36 | References: 37 | https://learn.microsoft.com/en-us/dotnet/api/system.timespan?view=net-7.0 38 | https://learn.microsoft.com/en-us/dotnet/api/system.timespan.-ctor?view=net-7.0 39 | https://learn.microsoft.com/en-us/dotnet/api/system.timespan.tickspersecond?view=net-7.0 40 | https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-timespan-format-strings?view=new-6.10 41 | 42 | 43 | The Constant ("c") Format Specifier: 44 | This specifier is not culture-sensitive. 45 | It produces the string representation of a TimeSpan value that is invariant 46 | and that's common to versions prior to .NET Framework 4. 47 | It takes the form [-][d'.']hh':'mm':'ss['.'fffffff] 48 | For example: 49 | 50 | 51 | 52 | >>> c(0, 0, 30, 0) 53 | '00:30:00' 54 | >>> from_string('00:30:00') 55 | datetime.timedelta(seconds=1800) 56 | 57 | >>> c(3, 17, 25, 30, 500) 58 | '3.17:25:30.5000000' 59 | >>> from_string('3.17:25:30.5000000') 60 | datetime.timedelta(days=3, seconds=62730, microseconds=500000) 61 | 62 | 63 | 64 | The General Short ("g") Format Specifier: 65 | This specifier outputs only what is needed. 66 | It is locale-sensitive and takes the form [-][d':']h':'mm':'ss[.FFFFFFF] 67 | For example: 68 | 69 | >>> g(1, 3, 16, 50, 500) 70 | '1:3:16:50.5' 71 | >>> from_string('1:3:16:50.5') 72 | datetime.timedelta(days=1, seconds=11810, microseconds=500000) 73 | 74 | >>> g(1, 3, 16, 50, 599) 75 | '1:3:16:50.599' 76 | >>> from_string('1:3:16:50.599') 77 | datetime.timedelta(days=1, seconds=11810, microseconds=599000) 78 | 79 | 80 | 81 | The General Long ("G") Format Specifier: 82 | This specifier always outputs days and seven fractional digits. 83 | It is locale-sensitive and takes the form [-]d':'hh':'mm':'ss.fffffff 84 | For example: 85 | 86 | >>> G(18, 30, 0) 87 | '0:18:30:00.0000000' 88 | >>> from_string('0:18:30:00.0000000') 89 | datetime.timedelta(seconds=66600) 90 | 91 | Running this file will invoke doctest, which will verify that the above running examples hold true. 92 | It will also invoke a test that checks the conversions for about 2 million different values. 93 | Microsecond-level precision is guaranteed. 94 | 95 | """ 96 | 97 | _TICKS_PER_SECOND = 1e7 98 | _PATTERN = re.compile( 99 | r'^' 100 | r'(?P-?)' 101 | r'(?P\d+[:.])?' 102 | r'(?P\d{1,2}):' 103 | r'(?P\d{2}):' 104 | r'(?P\d{2})' 105 | r'(?P[.,]\d{1,7})?' 106 | r'$' 107 | ) 108 | _FORMAT_SPECIFIERS = ('c', 'g', 'G') 109 | # Different locales use different decimal point characters. For example, en-US locale uses '.' and fr-FR locale uses ',' 110 | _NUMBER_DECIMAL_SEPARATOR = os.environ.get("TIMESPAN_LOCALE", locale.localeconv()["decimal_point"]) 111 | 112 | 113 | def _args_to_seconds(args, *, by_ticks=False) -> float: 114 | """ 115 | Converts a time information tuple to total seconds as float, passing through a timedelta object. 116 | @param args: variable-length tuple (size 1, 3, 4 or 5) specifying components of date and time. 117 | See the switch case of this function for full coverage of all input types. 118 | @param by_ticks: specifies input as ticks instead of seconds. 119 | @return: total seconds float. 120 | 121 | For example: 122 | >>> _args_to_seconds([100]) 123 | 100.0 124 | >>> _args_to_seconds([100], by_ticks=True) 125 | 1e-05 126 | >>> _args_to_seconds([1, 2]) 127 | Traceback (most recent call last): 128 | ... 129 | ValueError: Cannot initialize a TimeSpan instance with 2 arguments 130 | 131 | """ 132 | 133 | days, hours, minutes, seconds, milliseconds = 0, 0, 0, 0, 0 134 | 135 | if len(args) == 1: 136 | arg, = args 137 | if by_ticks: 138 | arg = arg / _TICKS_PER_SECOND 139 | seconds = arg 140 | 141 | elif len(args) == 3: 142 | # Initializes a new instance to a specified number of hours, minutes, and seconds. 143 | hours, minutes, seconds = args 144 | 145 | elif len(args) == 4: 146 | # Initializes a new instance to a specified number of days, hours, minutes, and seconds. 147 | days, hours, minutes, seconds = args 148 | 149 | elif len(args) == 5: 150 | # Initializes a new instance to a specified number of days, hours, minutes, seconds, and milliseconds. 151 | days, hours, minutes, seconds, milliseconds = args 152 | else: 153 | raise ValueError(f"Cannot initialize a TimeSpan instance with {len(args)} arguments") 154 | 155 | return datetime.timedelta( 156 | days=days, 157 | hours=hours, 158 | minutes=minutes, 159 | seconds=seconds, 160 | milliseconds=milliseconds, 161 | ).total_seconds() 162 | 163 | 164 | def to_string(specifier: str, *args: tuple) -> str: 165 | """ 166 | Converts date\time information (variable-length tuple) to a TimeSpan string in the current locale. 167 | @param specifier: format specifier. Options are 'c', 'g' and 'G'. 168 | @param args: variable-length tuple (size 1, 3, 4 or 5) specifying components of date and time. 169 | See the switch case of _args_to_seconds for full coverage of all input types. 170 | @return: TimeSpan string. 171 | See examples at module-level docs. 172 | """ 173 | assert specifier in _FORMAT_SPECIFIERS 174 | 175 | # convert the date\time information tuple to total seconds as float 176 | seconds: float = _args_to_seconds(args) 177 | 178 | # break seconds to components 179 | delta = datetime.timedelta(seconds=abs(seconds)) 180 | d = delta.days 181 | h, remainder = divmod(delta.seconds, 3600) 182 | m, s = divmod(remainder, 60) 183 | f = delta.microseconds * 10 184 | 185 | # apply format-specific style for hours, minutes and seconds 186 | h = str(h).zfill(1 if specifier == 'g' else 2) 187 | m = str(m).zfill(2) 188 | s = str(s).zfill(2) 189 | 190 | # apply format-specific style for fraction 191 | if f > 0 or specifier == 'G': 192 | f = ('.' if specifier == 'c' else _NUMBER_DECIMAL_SEPARATOR) + str(f).rjust(7, '0') 193 | if specifier == 'g': 194 | f = f.rstrip('0') 195 | else: 196 | f = '' 197 | 198 | # apply format-specific style for days 199 | if d > 0 or specifier == 'G': 200 | d = str(d) + ('.' if specifier == 'c' else ':') 201 | else: 202 | d = '' 203 | 204 | # build and return final string 205 | sign = '-' if seconds < 0 else '' 206 | return f"{sign}{d}{h}:{m}:{s}{f}" 207 | 208 | 209 | def from_string(timespan_string: str) -> datetime.timedelta: 210 | """ 211 | Converts a TimeSpan string in the current locale to a datetime.timedelta object. 212 | Analogous to TimeSpan.Parse - https://learn.microsoft.com/en-us/dotnet/api/system.timespan.parse?view=net-7.0 213 | @param timespan_string: TimeSpan string of any format and locale. 214 | @return: A timedelta object. 215 | See examples at module-level docs. 216 | """ 217 | 218 | groups = _PATTERN.match(timespan_string).groupdict() 219 | sign = -1 if groups['sign'] == '-' else 1 220 | days = sign * int((groups['days'] or '0').rstrip(':.')) 221 | hours = sign * int(groups['hours']) 222 | minutes = sign * int(groups['minutes']) 223 | seconds = sign * int(groups['seconds']) 224 | fraction = sign * float(groups['fraction'] or 0.0) 225 | delta = datetime.timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds + fraction) 226 | return delta 227 | 228 | 229 | def total_seconds(timespan_string: str) -> float: 230 | """ 231 | Converts a TimeSpan string in the current locale to a datetime.timedelta object. 232 | Analogous to TimeSpan.Parse with TimeSpan.TotalSeconds. 233 | See more: 234 | https://learn.microsoft.com/en-us/dotnet/api/system.timespan.parse?view=net-7.0 235 | https://learn.microsoft.com/en-us/dotnet/api/system.timespan.totalseconds?view=net-7.0 236 | @param timespan_string: TimeSpan string of any format and locale. 237 | @return: Total seconds as float. 238 | """ 239 | delta = from_string(timespan_string) 240 | seconds = delta.total_seconds() 241 | return seconds 242 | 243 | 244 | constant = partial(to_string, 'c') 245 | general_short = partial(to_string, 'g') 246 | general_long = partial(to_string, 'G') 247 | 248 | c = constant 249 | g = general_short 250 | G = general_long 251 | --------------------------------------------------------------------------------