diff --git a/Quickstart.ipynb b/Quickstart.ipynb index bdfa725..6026e06 100644 --- a/Quickstart.ipynb +++ b/Quickstart.ipynb @@ -7,9 +7,9 @@ "source": [ "## Propertime Quickstart\n", "\n", - "Propertime is an attempt at implementing proper time management in Python, by fully embracing the extra complications due to how we measure an manage time as humans instead of just negating them.\n", + "Propertime is an attempt at implementing proper time management in Python, by fully embracing the extra complications arising due to how we measure time as humans instead of just denying them.\n", "\n", - "In a nutshell, it provides two main classes: the ``Time`` class for representing time (similar to a datetime) and the ``TimeUnit`` class for representing units of time (similar to timedelta). Such classes play nice with Python datetimes so that you can mix and match and use them only when needed.\n", + "In a nutshell, it provides two main classes: the ``Time`` class for representing time (similar to a datetime) and the ``TimeSpan`` class for representing spans of time (similar to timedelta). Such classes play nice with Python datetimes so that you can mix and match and use them only when needed.\n", "\n", "You can have a look at the [README](https://github.com/sarusso/Propertime/blob/main/README.md) for a better introduction, some example usage and more info about Propertime.\n", "\n", @@ -53,7 +53,7 @@ { "data": { "text/plain": [ - "Time: 1703974824.0 (2023-12-30 22:20:24 UTC)" + "Time: 1705704718.0 (2024-01-19 22:51:58 UTC)" ] }, "execution_count": 2, @@ -82,7 +82,7 @@ { "data": { "text/plain": [ - "1703974824.0" + "1705704718.0" ] }, "execution_count": 3, @@ -111,7 +111,7 @@ { "data": { "text/plain": [ - "Time: 1703974824.0 (2023-12-30 23:20:24 Europe/Rome)" + "Time: 1705704718.0 (2024-01-19 23:51:58 Europe/Rome)" ] }, "execution_count": 4, @@ -132,7 +132,7 @@ { "data": { "text/plain": [ - "Time: 1703974824.0 (2023-12-30 02:20:24 -20:00)" + "Time: 1705704718.0 (2024-01-19 02:51:58 -20:00)" ] }, "execution_count": 5, @@ -141,7 +141,7 @@ } ], "source": [ - "Time(offset=-72000)" + "Time(tz=None, offset=-72000)" ] }, { @@ -149,7 +149,7 @@ "id": "047aab39-6b7f-4535-ae90-af8d9377bf6d", "metadata": {}, "source": [ - "To instead create a Time instance at a given time, either use Epoch seconds, classic datetime-like arguments, a (ISO) string representation or a datetime object. Naive strings or datetimes without an extra time zone or UTC offset are always assumed on UTC, as Propertime does not allow for naive Time instances." + "To instead create a Time instance at a given time, either use Epoch seconds, classic datetime-like arguments." ] }, { @@ -194,6 +194,14 @@ "Time(2023,12,3,16,12,0)" ] }, + { + "cell_type": "markdown", + "id": "2e3c5cd3-1a0c-48c3-b277-c4d95e4fccb5", + "metadata": {}, + "source": [ + "You can also create Time form (ISO) string representation or a datetime objects, using their respective class methods. Naive strings or datetimes without explicitly setitng the ime zone or offset are not allowed, as Propertime does not allow for naive Time instances." + ] + }, { "cell_type": "code", "execution_count": 8, @@ -212,7 +220,7 @@ } ], "source": [ - "Time('2023-12-25T16:12:00+01:00', tz='Europe/Rome')" + "Time.from_iso('2023-12-25T16:12:00+01:00', tz='Europe/Rome')" ] }, { @@ -224,7 +232,7 @@ { "data": { "text/plain": [ - "Time: 1701619920.0 (2023-12-03 16:12:00 UTC)" + "Time: 1701648720.0 (2023-12-03 16:12:00 America/Los_Angeles)" ] }, "execution_count": 9, @@ -234,7 +242,7 @@ ], "source": [ "from datetime import datetime\n", - "Time(datetime(2023,12,3,16,12,0))" + "Time.from_dt(datetime(2023,12,3,16,12,0), tz='America/Los_Angeles')" ] }, { @@ -254,7 +262,7 @@ { "data": { "text/plain": [ - "Time: 1701620620.0 (2023-12-03 16:23:40 UTC)" + "Time: 1701621180.0 (2023-12-03 16:33:00 UTC)" ] }, "execution_count": 10, @@ -263,7 +271,9 @@ } ], "source": [ - "arrival_times = [Time(datetime(2023,12,3,16,12,0)), Time(datetime(2023,12,3,16,56,0)), Time(datetime(2023,12,3,16,3,0))]\n", + "arrival_times = [Time.from_dt(datetime(2023,12,3,16,12,0), tz='UTC'),\n", + " Time.from_dt(datetime(2023,12,3,16,56,0), tz='UTC'),\n", + " Time.from_dt(datetime(2023,12,3,16,31,0), tz='UTC')]\n", "Time(sum(arrival_times)/len(arrival_times))" ] }, @@ -282,7 +292,7 @@ "metadata": {}, "outputs": [], "source": [ - "time = Time(datetime(2023,12,3,16,12,0))" + "time = Time(2023,12,3,16,12,0)" ] }, { @@ -303,7 +313,7 @@ } ], "source": [ - "time.dt()" + "time.to_dt()" ] }, { @@ -324,20 +334,30 @@ } ], "source": [ - "time.iso()" + "time.to_iso()" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f4b4b29-a4b3-4e0d-a163-5d4ad7205e5f", + "metadata": {}, + "outputs": [], + "source": [] + }, { "cell_type": "markdown", "id": "c298886e-13c2-4e82-9cf4-4b9b5dc9513d", "metadata": {}, "source": [ - "### The TimeUnit class\n", - "Time units impement precise, calendar-awarare time arithmetic and can hopefully reduce the headaches caused by time manipulation operations. Their main charachteristic is to embarace time units with *variable* length (e.g a day can last 23, 24 or 25 hours) and to correctly handle all the various edge cases.\n", + "### The TimeSpan class\n", + "Time spans impement precise, calendar-awarare time arithmetic based on durations and can hopefully reduce the headaches caused by time manipulation operations. Their main charachteristic is to embarace that, as soon a a calendar component kicks-in, a time span can have *variable* length: a day can last 23, 24 or 25 hours, while an hour will alwyas last 3600 seconds (leap seconds apart).\n", + "\n", + "Morover, time spans' arithmetic fully acknowledge that some time manipulation operations are not always well defined (e.g. adding a month to the 31st of January) and that should raise an error, exactly as a divison by zero would.\n", "\n", - "Morover, TimeUnits' arithmetic also accepts that some operations can be not well defined (e.g. adding a month to the 31st of January) and that should raise an error, exactly as a divison by zero would.\n", + "Time spans can be instantiated either by by manually setting all of their components (years, months, weeks, days, hours, minutes, seconds and microseconds), or using their string representation: `1s` for one second, `1m` for one minute, `1h` for one hour, `1D` for one day, `1M` for one month, `1W` for one weelk and `1Y` for one year. Values other than \"one\" are of course supported, as well as combinations (to a certian degree).\n", "\n", - "TimeUnits can be instantiated by setting their length in seconds, by manually setting all their components (as for Python datetime objets) or using their string representation: `1s` for one second, `1m` for one minute, `1h` for one hour, `1D` for one day, `1M` for one month, `1W` for one weelk and `1Y` for one year (Physical time units are lowercase, calendar time units are uppercase). Other values other than one are of course supported.\n" + "Some examples follows:" ] }, { @@ -358,8 +378,8 @@ } ], "source": [ - "from propertime import TimeUnit\n", - "TimeUnit('1h')" + "from propertime import TimeSpan\n", + "TimeSpan('1h')" ] }, { @@ -369,15 +389,7 @@ "metadata": {}, "outputs": [], "source": [ - "assert TimeUnit('1h') == TimeUnit('3600s') == TimeUnit(3600) == TimeUnit(hours=1)" - ] - }, - { - "cell_type": "markdown", - "id": "a5ccd29a-09f5-44a1-bcf2-27d4cac048c6", - "metadata": {}, - "source": [ - "Composite time units are possible as well:" + "assert TimeSpan('1h') == TimeSpan('3600s') == TimeSpan(seconds=3600) == TimeSpan(hours=1)" ] }, { @@ -385,20 +397,9 @@ "execution_count": 16, "id": "76c2cec9-0def-4b71-8679-c79fb9404bf0", "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "1h_30m" - ] - }, - "execution_count": 16, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ - "TimeUnit('1h_30m')" + "assert TimeSpan('1h_30m') == TimeSpan(hours=1, minutes=30)" ] }, { @@ -406,7 +407,7 @@ "id": "a50d64c8-146d-4681-b67b-0f21d64867bd", "metadata": {}, "source": [ - "Time units with fixed lenght can always get as seconds:" + "Fixed-length time spans can be always converted as seconds:" ] }, { @@ -427,110 +428,84 @@ } ], "source": [ - "TimeUnit('1h_30m').as_seconds()" + "TimeSpan('1h_30m').as_seconds()" ] }, { "cell_type": "markdown", - "id": "e3dfac32-bb03-4cc0-866a-946a776beb3a", + "id": "8d0268ec-8854-4cfc-9302-1c7d53142507", "metadata": {}, "source": [ - "However, variable lenght time units cannot be quantified in terms of seconds if not contextualised at a given time and ona given timezone: how a one-day time unit last depends on the day, and must therefore know it. The `start` argument servers for this puropose: to \"fix\" a variable lenght time units thus allowing to get the duration as seconds. It can be either a Time od datetime object." + "...but this does not hold true for variable-lenght time spans, or in other words if there is a calendar component involved:" ] }, { "cell_type": "code", "execution_count": 18, - "id": "adf0cb8a-3770-459d-a77f-539492519b8d", + "id": "b269be57-98ba-44bc-8548-f0e67180b58f", "metadata": {}, "outputs": [ { - "data": { - "text/plain": [ - "82800.0" - ] - }, - "execution_count": 18, - "metadata": {}, - "output_type": "execute_result" + "name": "stdout", + "output_type": "stream", + "text": [ + "You can ask to get a calendar TimeSpan as seconds only if you provide the span starting point\n" + ] } ], "source": [ - "TimeUnit('1D').as_seconds(start=Time(2023, 3, 26, 0, 0, tz='Europe/Rome'))" + "try:\n", + " TimeSpan('1D').as_seconds()\n", + "except Exception as e:\n", + " print(e)" ] }, { "cell_type": "markdown", - "id": "2266c91d-734a-433e-bfd4-a4105099865d", + "id": "e3dfac32-bb03-4cc0-866a-946a776beb3a", "metadata": {}, "source": [ - "TimeUnits can indeed be of two main types:\n", - "\n", - " - \"physical\", which have an always fixed legth (as seconds, minutes and hours)\n", - " - \"calendar\", which have a variable lenght depending on a calendar (as days, weeks, months, and years).\n", - "\n", - "This abstraction allows to clearly state that a (fixed) 24-hours time unit is different than a one-day time unit (that can last 23, 24 or 25 hours):" + "This is because, as already mentioned, variable lenght time spans cannot be quantified in terms of seconds if not contextualised at a given time and on a given time zone: how many seconds a one-day time span lasts depends on the day. The `starting_at` argument servers for this puropose: to contextualise a variable lenght time span, thus allowing to compute its duration as seconds:" ] }, { "cell_type": "code", "execution_count": 19, - "id": "6aa45187-3108-43fb-ba08-97c87ce59350", - "metadata": {}, - "outputs": [], - "source": [ - "assert TimeUnit('24h') == TimeUnit('86400s') \n", - "assert TimeUnit('1D') != TimeUnit('86400s')" - ] - }, - { - "cell_type": "markdown", - "id": "3864a88b-8b67-4e01-9f71-218a1ce8730d", - "metadata": {}, - "source": [ - "To get the type of a time unit just use the `type` attirbute:" - ] - }, - { - "cell_type": "code", - "execution_count": 20, - "id": "bc116712-b46c-4d97-851a-567d973a741b", + "id": "adf0cb8a-3770-459d-a77f-539492519b8d", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "'Physical'" + "82800.0" ] }, - "execution_count": 20, + "execution_count": 19, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "TimeUnit('24h').type" + "TimeSpan('1D').as_seconds(starting_at=Time(2023, 3, 26, 0, 0, tz='Europe/Rome'))" + ] + }, + { + "cell_type": "markdown", + "id": "2266c91d-734a-433e-bfd4-a4105099865d", + "metadata": {}, + "source": [ + "Please note indeed that Propertime allows to clearly state that a (fixed) 24-hours time span is different than a one-day time span (that can last 23, 24 or 25 hours):" ] }, { "cell_type": "code", - "execution_count": 21, - "id": "4b6dc86a-ea86-4c77-b1bc-12fe860ad33f", + "execution_count": 20, + "id": "6aa45187-3108-43fb-ba08-97c87ce59350", "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Calendar'" - ] - }, - "execution_count": 21, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ - "TimeUnit('1D').type" + "assert TimeSpan('24h') == TimeSpan('86400s') \n", + "assert TimeSpan('1D') != TimeSpan('86400s')" ] }, { @@ -538,12 +513,12 @@ "id": "799c49e4-bb03-4813-ad1d-a061485f8741", "metadata": {}, "source": [ - "Time units can be added and subtracted each other and to Time and datetime objects, in which case their context is automatically defined, thus allowing to maniplate time in a consistent way:" + "Time spans can be added and subtracted each other and to Time and datetime objects, in which case their context is automatically defined, thus allowing to maniplate time in a consistent way:" ] }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 21, "id": "361e778a-8a17-457f-8efa-da44e613d37c", "metadata": {}, "outputs": [ @@ -553,13 +528,13 @@ "Time: 1675209600.0 (2023-02-01 00:00:00 UTC)" ] }, - "execution_count": 22, + "execution_count": 21, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "Time(2023,1,1,0,0,0) + TimeUnit('1M')" + "Time(2023,1,1,0,0,0) + TimeSpan('1M')" ] }, { @@ -567,12 +542,12 @@ "id": "c60849be-bc4f-41a9-b6aa-17f6fd4d9c3b", "metadata": {}, "source": [ - "Time units can also be used to round, ceil or floor time." + "Time spans can also be used to round, ceil or floor time." ] }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 22, "id": "2fd673c6-637a-4f7c-b646-9228f1c094a8", "metadata": {}, "outputs": [ @@ -582,18 +557,18 @@ "Time: 1698624000.0 (2023-10-30 00:00:00 UTC)" ] }, - "execution_count": 23, + "execution_count": 22, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "TimeUnit('1D').round(Time(2023,10,29,16,0,0))" + "TimeSpan('1D').round(Time(2023,10,29,16,0,0))" ] }, { "cell_type": "code", - "execution_count": 24, + "execution_count": 23, "id": "6c546727-0468-49fe-b00d-de44ff16565f", "metadata": {}, "outputs": [ @@ -603,18 +578,18 @@ "Time: 1675123200.0 (2023-01-31 00:00:00 UTC)" ] }, - "execution_count": 24, + "execution_count": 23, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "TimeUnit('1D').floor(Time(2023,1,31,19,21,47))" + "TimeSpan('1D').floor(Time(2023,1,31,19,21,47))" ] }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 24, "id": "e4bff65f-ee59-4c41-84e7-5d1c35d46618", "metadata": {}, "outputs": [ @@ -624,13 +599,13 @@ "Time: 1675279307.0 (2023-02-01 19:21:47 UTC)" ] }, - "execution_count": 25, + "execution_count": 24, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "TimeUnit('1D').shift(Time(2023,1,31,19,21,47))" + "TimeSpan('1D').shift(Time(2023,1,31,19,21,47))" ] }, { @@ -643,7 +618,7 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": 25, "id": "d07380ee-ec05-490e-a029-7e38fc3cbb47", "metadata": {}, "outputs": [ @@ -653,14 +628,14 @@ "Time: 1698663600.0 (2023-10-30 12:00:00 Europe/Rome)" ] }, - "execution_count": 26, + "execution_count": 25, "metadata": {}, "output_type": "execute_result" } ], "source": [ "time = Time(2023,10,29,0,15,39, tz='Europe/Rome')\n", - "TimeUnit('1D').ceil(time) + TimeUnit('12h')" + "TimeSpan('1D').ceil(time) + TimeSpan('12h')" ] }, { @@ -668,12 +643,12 @@ "id": "823d467a-c4c5-4166-b640-273e34bbc9d9", "metadata": {}, "source": [ - "TimeUnits are also useful for \"slotting\" time, while taking care about all the DST extra complications. For example, to slot a day in 1-hour bins: " + "Time spans are also useful for \"slotting\" time, while taking care about all the DST extra complications. For example, to slot a day in 1-hour bins: " ] }, { "cell_type": "code", - "execution_count": 27, + "execution_count": 26, "id": "66648a68-0289-4607-b252-40817198b062", "metadata": {}, "outputs": [ @@ -711,12 +686,12 @@ ], "source": [ "start = Time(2023,10,29,0,0,0, tz='Europe/Rome')\n", - "end = start + TimeUnit('1D')\n", + "end = start + TimeSpan('1D')\n", "\n", "slot_strart_time = start\n", "while slot_strart_time < end:\n", " print(slot_strart_time)\n", - " slot_strart_time = slot_strart_time + TimeUnit('1h')" + " slot_strart_time = slot_strart_time + TimeSpan('1h')" ] }, { @@ -747,7 +722,7 @@ }, { "cell_type": "code", - "execution_count": 28, + "execution_count": 27, "id": "e0298b7d-ff98-4fe1-93b7-468ac620a0ab", "metadata": {}, "outputs": [ @@ -761,7 +736,7 @@ ], "source": [ "try:\n", - " Time(2023,1,31,0,0,0) + TimeUnit('1M')\n", + " Time(2023,1,31,0,0,0) + TimeSpan('1M')\n", "except Exception as e:\n", " print(e)" ] @@ -776,7 +751,7 @@ }, { "cell_type": "code", - "execution_count": 29, + "execution_count": 28, "id": "b750ccb1-35a7-44e1-bfb4-9627fd0b471e", "metadata": {}, "outputs": [ @@ -790,7 +765,7 @@ ], "source": [ "try:\n", - " Time(2023,3,25,2,15,0, tz='Europe/Rome') + TimeUnit('1D')\n", + " Time(2023,3,25,2,15,0, tz='Europe/Rome') + TimeSpan('1D')\n", "except Exception as e:\n", " print(e)" ] @@ -805,7 +780,7 @@ }, { "cell_type": "code", - "execution_count": 30, + "execution_count": 29, "id": "aa0a785e-4620-4398-9fda-af82b6fb8f01", "metadata": {}, "outputs": [ @@ -834,7 +809,7 @@ }, { "cell_type": "code", - "execution_count": 31, + "execution_count": 30, "id": "4fcfaf50-7a8a-4d31-aadc-58affb7b202e", "metadata": {}, "outputs": [ @@ -848,7 +823,7 @@ ], "source": [ "try:\n", - " Time(2023,10,28,2,15,0, tz='Europe/Rome')+ TimeUnit('1D')\n", + " Time(2023,10,28,2,15,0, tz='Europe/Rome')+ TimeSpan('1D')\n", "except Exception as e:\n", " print(e)" ] @@ -863,7 +838,7 @@ }, { "cell_type": "code", - "execution_count": 32, + "execution_count": 31, "id": "9520ef40-c5e2-4207-90dd-af704ae39cbf", "metadata": {}, "outputs": [ @@ -871,7 +846,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Sorry, time 2023-11-05 01:15:00 is ambiguous on time zone America/New_York without an offset\n" + "Sorry, time 2023-11-05 01:15:00 is ambiguous on time zone America/New_York without an offset. Use guessing=True to allow creating it with a guess.\n" ] } ], @@ -892,7 +867,7 @@ }, { "cell_type": "code", - "execution_count": 33, + "execution_count": 32, "id": "07a88b10-97ac-4291-9591-cb59380440b6", "metadata": {}, "outputs": [ @@ -909,7 +884,7 @@ "Time: 1699164900.0 (2023-11-05 01:15:00 America/New_York)" ] }, - "execution_count": 33, + "execution_count": 32, "metadata": {}, "output_type": "execute_result" } @@ -923,38 +898,17 @@ "id": "4ccc2628-20f2-4733-b504-64c636ccb3c1", "metadata": {}, "source": [ - "To get the other one, either set the offset (explicitly or with a string conversion, it's the same):" + "To get the other one, you need to explicitly set the offset:" ] }, { "cell_type": "code", - "execution_count": 34, + "execution_count": 33, "id": "e6dc0fb3-1b67-4b35-a958-3ad9d8765df4", "metadata": {}, "outputs": [], "source": [ - "assert Time(2023,11,5,1,15,0, offset=-3600*4, tz='America/New_York') == Time('2023-11-05T01:15:00-04:00', tz='America/New_York')" - ] - }, - { - "cell_type": "code", - "execution_count": 35, - "id": "e9f0a446-6bea-4524-91a5-f5c4e0a1098d", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "Time: 1699161300.0 (2023-11-05 01:15:00 America/New_York DST)" - ] - }, - "execution_count": 35, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "Time(2023,11,5,1,15,0, offset=-3600*4, tz='America/New_York')" + "assert Time(2023,11,5,1,15,0, offset=-3600*4, tz='America/New_York') == Time.from_iso('2023-11-05T01:15:00-04:00', tz='America/New_York')" ] }, { @@ -962,12 +916,12 @@ "id": "cb43116b-e9be-403f-91ed-5d8de9582463", "metadata": {}, "source": [ - "...or just add the necessary hours to a previous point in time, which is an operation always well defined:" + "...or just add the necessary hours to a previous point in time, which is an operation always allowed:" ] }, { "cell_type": "code", - "execution_count": 36, + "execution_count": 34, "id": "741c74dc-a327-4c8b-984e-7058e2680a79", "metadata": {}, "outputs": [ @@ -977,18 +931,18 @@ "Time: 1699161300.0 (2023-11-05 01:15:00 America/New_York DST)" ] }, - "execution_count": 36, + "execution_count": 34, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "Time(2023,11,5,0,15,0, tz='America/New_York') + TimeUnit('1h')" + "Time(2023,11,5,0,15,0, tz='America/New_York') + TimeSpan('1h')" ] }, { "cell_type": "code", - "execution_count": 37, + "execution_count": 35, "id": "12d3f1b6-ff6e-4418-9240-5a9aae32ed60", "metadata": {}, "outputs": [ @@ -998,13 +952,13 @@ "Time: 1699164900.0 (2023-11-05 01:15:00 America/New_York)" ] }, - "execution_count": 37, + "execution_count": 35, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "Time(2023,11,5,0,15,0, tz='America/New_York') + TimeUnit('2h')" + "Time(2023,11,5,0,15,0, tz='America/New_York') + TimeSpan('2h')" ] }, { diff --git a/README.md b/README.md index f570c99..f416f4f 100644 --- a/README.md +++ b/README.md @@ -4,25 +4,24 @@ An attempt at proper time management in Python. [![Tests status](https://github.com/sarusso/Propertime/actions/workflows/ci.yml/badge.svg)](https://github.com/sarusso/Propertime/actions) [![Licence Apache 2](https://img.shields.io/github/license/sarusso/Propertime)](https://github.com/sarusso/Propertime/blob/main/LICENSE) [![Semver 2.0.0](https://img.shields.io/badge/semver-v2.0.0-blue)](https://semver.org/spec/v2.0.0.html) - ## Introduction -Propertime is an attempt to implement proper time management in Python, by fully embracing the additional complications due to how we measure time as humans instead of just denying them. +Propertime is an attempt to implement proper time management in Python, by fully embracing the extra complications arising due to how we measure time as humans instead of just denying them. -These include but are not limited to: differences between physical and calendar time, time zones, offsets, daylight saving times, undefined calendar time operations and variable length time units. +These include but are not limited to: differences between physical and calendar time, time zones, offsets, daylight saving times, undefined calendar time operations and variable length time spans. -In a nutshell, Propertime provides two main classes: the ``Time`` class for representing time (similar to a datetime) and the ``TimeUnit`` class for representing units of time (similar to timedelta). +In a nutshell, Propertime provides two main classes: the ``Time`` class for representing time (similar to a datetime) and the ``TimeSpan`` class for representing spans of time (similar to timedelta). -Such classes are implemented assuming two strict base hypotheses: +Such classes are implemented assuming two strict hypotheses: - **Time** is a floating point number corresponding the number of seconds after the zero on the time axis (Epoch), which is set to 1st January 1970 UTC. Any other representations (as dates and hours, time zones, daylight saving times) are just derivatives. -- **Time units** can be both of fixed length (for physical time as seconds, minutes, hours) and of *variable* length (for calendar time as days, weeks, months, years). This means that the length (i.e. the duration in seconds) of a calendar time unit is not defined *unless* it is put in a specific context, or in other words to know when it is applied. +- **Time spans** can be either of fixed length (for physical time as seconds, minutes, hours) and of *variable* length (for calendar time as days, weeks, months, years). This means that the length (i.e. the duration in seconds) of a time span involving calendar components it is not defined *unless* in a specific context, or in other words knowing when it is applied. These two assumptions allow Propertime to solve by design many issues in manipulating time that are still present in Python's built-in datetime module as well as in most third-party libraries. -Implementing "proper" time comes however at a price: it optimizes for consistency over performance and it is quite strict. Whether it is a suitable solution for you or not, it heavily depends on the use case. +Implementing "proper" time comes at a price, though: the library is optimized for consistency over performance and its interfaces are quite strict. Whether it is a suitable solution for you or not, this heavily depends on the use case. Propertime provides a simple and neat API, it is relatively well tested and its objects play nice with Python datetimes so that you can mix and match and use it only when needed. @@ -39,14 +38,13 @@ It has just a few requirements, listed in the ``requirements.txt`` file, which y ## Example usage ```python -from propertime import Time, TimeUnit +# Time +from propertime import Time, Timespan time_now = Time() # If no arguments, time is now time = Time(1703517120.0) # Init from Epoch (UTC) -time = Time('2023-12-25T16:12:00+01:00') # Init from string (an offset is set) - time = Time(2023,5,6,13,45) # Init datetime-like. Defaults to UTC, there is no # such thing as naive time (without time zone/offset) @@ -59,15 +57,22 @@ time = Time(2023,11,5,1,15, tz='US/Eastern') # This is ambiguous: there are time = Time(2023,3,12,2,30, tz='US/Eastern') # This just does not exist on # US/Eastern, due to DST change -time + TimeUnit('1D') # Tomorrow same hour. Not defined when DTS starts, and +# Time spans +time + TimeSpan('1D') # Tomorrow same hour. Not defined when DTS starts, and # ambiguous when DTS ends -time + TimeUnit('24h') # Tomorrow same hour unless applied over a DTS change, +time + TimeSpan('24h') # Tomorrow same hour unless applied over a DTS change, # where there will be a plus or minus 1 hour difference -time + TimeUnit('1M') # Next month same day. Not defined if the destination day +time + TimeSpan('1M') # Next month same day. Not defined if the destination day # does not exist (i.e. 30th of February) + +# Conversions +time = Time.from_iso('2023-12-25T16:12:00+01:00') # Convert from ISO string, + # similar for datetimes + +time.to_iso() # Convert to ISO string, similar for datetimes ``` diff --git a/docs/propertime.time.rst b/docs/propertime.time.rst index 1c50b33..0ea61e0 100644 --- a/docs/propertime.time.rst +++ b/docs/propertime.time.rst @@ -18,7 +18,7 @@ .. autosummary:: Time - TimeUnit + TimeSpan diff --git a/docs/propertime.utilities.rst b/docs/propertime.utilities.rst index 8949aea..516c1c8 100644 --- a/docs/propertime.utilities.rst +++ b/docs/propertime.utilities.rst @@ -18,6 +18,7 @@ dt dt_from_s dt_from_str + get_offset_from_dt get_tz_offset is_dt_ambiguous_without_offset is_dt_inconsistent diff --git a/propertime/__init__.py b/propertime/__init__.py index 5f1eba2..74ba53b 100644 --- a/propertime/__init__.py +++ b/propertime/__init__.py @@ -1 +1 @@ -from .time import Time, TimeUnit \ No newline at end of file +from .time import Time, TimeSpan \ No newline at end of file diff --git a/propertime/tests/test_time.py b/propertime/tests/test_time.py index 4afa61e..8cdfa1f 100644 --- a/propertime/tests/test_time.py +++ b/propertime/tests/test_time.py @@ -4,7 +4,7 @@ import pytz from datetime import datetime from ..utilities import dt, correct_dt_dst, str_from_dt, dt_from_str, s_from_dt, dt_from_s, as_tz, timezonize, now_s -from ..time import Time, TimeUnit +from ..time import Time, TimeSpan from dateutil.tz.tz import tzoffset try: from zoneinfo import ZoneInfo @@ -79,7 +79,7 @@ def test_init(self): # Time with time zone time = Time(1702928535.0, tz='America/New_York') self.assertEqual(str(time.tz), 'America/New_York') - self.assertEqual(time.offset, -68400) + self.assertEqual(time.offset, -18000) time = Time(1702928535.0, tz='Europe/Rome') self.assertEqual(str(time.tz), 'Europe/Rome') @@ -174,6 +174,8 @@ def test_init(self): self.assertEqual(str(time), 'Time: 1698538500.0 (2023-10-29 02:15:00 Europe/Rome DST)') self.assertEqual(time.to_iso(), '2023-10-29T02:15:00+02:00') + time = Time(2023,11,5,1,15,0, offset=-3600*4, tz='America/New_York') + print(time) def test_conversions(self): @@ -259,7 +261,10 @@ def test_conversions(self): # Ambiguous time with self.assertRaises(ValueError): - print(Time.from_dt(timezonize('Europe/Rome').localize(datetime(2023,10,29,2,15,0)))) + Time.from_dt(datetime(2023,10,29,2,15,0), tz='Europe/Rome') + + # This is not ambiguous for the from_dt method. It was for the .localize(). + time = Time.from_dt(timezonize('Europe/Rome').localize(datetime(2023,10,29,2,15,0))) # Ambiguous time with guessing enabled (just raises a warning) time = Time(2023,10,29,2,15,0, tz='Europe/Rome', guessing=True) @@ -335,7 +340,7 @@ def test_conversions(self): time = Time.from_iso('1986-08-01T16:46:00+02:00', tz='America/New_York') self.assertEqual(time, 523291560.0) self.assertEqual(str(time.tz), 'America/New_York') - self.assertEqual(time.offset, -72000) + self.assertEqual(time.offset, -14400) # Time from string with an offset and both an extra offset and time zone as arguments # Expected behavior: move to the given time zone, check offset compatibility @@ -356,7 +361,7 @@ def test_conversions(self): with self.assertRaises(ValueError): Time.from_iso('2023-06-11T17:56:00+03:00', offset=10800, tz='Europe/Rome') # 14:56 UTC - # Extra check for otherwise ambiguous time (TODO: maybe move elsewhere?) + # Extra check for otherwise ambiguous time time = Time.from_iso('2023-10-29T02:15:00+01:00', tz='Europe/Rome') self.assertEqual(str(time), 'Time: 1698542100.0 (2023-10-29 02:15:00 Europe/Rome)') @@ -456,101 +461,107 @@ def test_operations(self): -class TestTimeUnits(unittest.TestCase): +class TestTimeSpans(unittest.TestCase): - def test_TimeUnit(self): + def test_TimeSpan(self): with self.assertRaises(ValueError): - _ = TimeUnit('15m', '20s') + _ = TimeSpan('15m', '20s') # Not valid 'q' type with self.assertRaises(ValueError): - _ = TimeUnit('15q') + _ = TimeSpan('15q') # Numerical init - time_unit_1 = TimeUnit(60) - self.assertEqual(str(time_unit_1), '60s') + time_span_1 = TimeSpan(seconds=60) + self.assertEqual(str(time_span_1), '60s') # String init - time_unit_1 = TimeUnit('15m') - self.assertEqual(str(time_unit_1), '15m') + time_span_1 = TimeSpan('15m') + self.assertEqual(str(time_span_1), '15m') - time_unit_2 = TimeUnit('15m_30s_3u') - self.assertEqual(str(time_unit_2), '15m_30s_3u') + time_span_2 = TimeSpan('15m_30s_3u') + self.assertEqual(str(time_span_2), '15m_30s_3u') # Components init - self.assertEqual(TimeUnit(days=1).days, 1) - self.assertEqual(TimeUnit(years=2).years, 2) - self.assertEqual(TimeUnit(minutes=1).minutes, 1) - self.assertEqual(TimeUnit(minutes=15).minutes, 15) - self.assertEqual(TimeUnit(hours=1).hours, 1) + self.assertEqual(TimeSpan(days=1).days, 1) + self.assertEqual(TimeSpan(years=2).years, 2) + self.assertEqual(TimeSpan(minutes=1).minutes, 1) + self.assertEqual(TimeSpan(minutes=15).minutes, 15) + self.assertEqual(TimeSpan(hours=1).hours, 1) # Test various init and correct handling of time componentes - self.assertEqual(TimeUnit('1D').days, 1) - self.assertEqual(TimeUnit('2Y').years, 2) - self.assertEqual(TimeUnit('1m').minutes, 1) - self.assertEqual(TimeUnit('15m').minutes, 15) - self.assertEqual(TimeUnit('1h').hours, 1) + self.assertEqual(TimeSpan('1D').days, 1) + self.assertEqual(TimeSpan('2Y').years, 2) + self.assertEqual(TimeSpan('1m').minutes, 1) + self.assertEqual(TimeSpan('15m').minutes, 15) + self.assertEqual(TimeSpan('1h').hours, 1) # Test floating point seconds init - self.assertEqual(TimeUnit('1.2345s').as_seconds(), 1.2345) - self.assertEqual(TimeUnit('1.234s').as_seconds(), 1.234) - self.assertEqual(TimeUnit('1.02s').as_seconds(), 1.02) - self.assertEqual(TimeUnit('1.000005s').as_seconds(), 1.000005) - self.assertEqual(TimeUnit('67.000005s').seconds, 67) - self.assertEqual(TimeUnit('67.000005s').microseconds, 5) + self.assertEqual(TimeSpan('1.2345s').as_seconds(), 1.2345) + self.assertEqual(TimeSpan('1.234s').as_seconds(), 1.234) + self.assertEqual(TimeSpan('1.02s').as_seconds(), 1.02) + self.assertEqual(TimeSpan('1.000005s').as_seconds(), 1.000005) + self.assertEqual(TimeSpan('67.000005s').seconds, 67) + self.assertEqual(TimeSpan('67.000005s').microseconds, 5) # Too much precision (below microseconds), gets cut - time_unit = TimeUnit('1.0000005s') - self.assertEqual(str(time_unit),'1s') - time_unit = TimeUnit('1.0000065s') - self.assertEqual(str(time_unit),'1s_6u') + time_span = TimeSpan('1.0000005s') + self.assertEqual(str(time_span),'1s') + time_span = TimeSpan('1.0000065s') + self.assertEqual(str(time_span),'1s_6u') - # Test unit values - self.assertEqual(TimeUnit(600).value, '600s') # Int converted to string representation - self.assertEqual(TimeUnit(600.0).value, '600s') # Float converted to string representation - self.assertEqual(TimeUnit(600.45).value, '600s_450000u') # Float converted to string representation (using microseconds) + # Test span values + self.assertEqual(TimeSpan(seconds=600).value, '600s') # Int converted to string representation + self.assertEqual(TimeSpan(seconds=600.0).value, '600s') # Float converted to string representation + self.assertEqual(TimeSpan(seconds=600.45).value, '600s_450000u') # Float converted to string representation (using microseconds) + self.assertEqual(TimeSpan(seconds=600.456).value, '600s_456000u') # Float converted to string representation (using microseconds) - self.assertEqual(TimeUnit(days=1).value, '1D') - self.assertEqual(TimeUnit(years=2).value, '2Y') - self.assertEqual(TimeUnit(minutes=1).value, '1m') - self.assertEqual(TimeUnit(minutes=15).value, '15m') - self.assertEqual(TimeUnit(hours=1).value, '1h') + self.assertEqual(TimeSpan(days=1).value, '1D') + self.assertEqual(TimeSpan(years=2).value, '2Y') + self.assertEqual(TimeSpan(minutes=1).value, '1m') + self.assertEqual(TimeSpan(minutes=15).value, '15m') + self.assertEqual(TimeSpan(hours=1).value, '1h') - self.assertEqual(time_unit_1.value, '15m') - self.assertEqual(time_unit_2.value, '15m_30s_3u') - self.assertEqual(TimeUnit(days=1).value, '1D') # This is obtained using the unit's string representation + self.assertEqual(time_span_1.value, '15m') + self.assertEqual(time_span_2.value, '15m_30s_3u') + self.assertEqual(TimeSpan(days=1).value, '1D') # This is obtained using the span's string representation - # Test unit equalities and inequalities - self.assertTrue(TimeUnit(hours=1) == TimeUnit(hours=1)) - self.assertTrue(TimeUnit(hours=1) == '1h') - self.assertFalse(TimeUnit(hours=1) == TimeUnit(hours=2)) - self.assertFalse(TimeUnit(hours=1) == 'a_string') + # Test span simple equalities and inequalities + self.assertEqual(TimeSpan(hours=1), TimeSpan(hours=1)) + self.assertEqual(TimeSpan(hours=1), '1h') + self.assertNotEqual(TimeSpan(hours=1), TimeSpan(hours=2)) + self.assertNotEqual(TimeSpan(hours=1), 'a_string') - self.assertTrue(TimeUnit(days=1) == TimeUnit(days=1)) - self.assertTrue(TimeUnit(days=1) == '1D') - self.assertFalse(TimeUnit(days=1) == TimeUnit(days=2)) - self.assertFalse(TimeUnit(days=1) == 'a_string') + self.assertEqual(TimeSpan(days=1), TimeSpan(days=1)) + self.assertEqual(TimeSpan(days=1), '1D') + self.assertNotEqual(TimeSpan(days=1), TimeSpan(days=2)) + self.assertNotEqual(TimeSpan(days=1), 'a_string') - self.assertFalse(TimeUnit('86400s') == TimeUnit('1D')) + # Test span composite equalities and inequalities + self.assertEqual(TimeSpan('1h'), TimeSpan('3600s')) + self.assertEqual(TimeSpan('1h'), TimeSpan(hours=1)) + self.assertNotEqual(TimeSpan('24h'), TimeSpan('1D')) + self.assertNotEqual(TimeSpan('86400s'), TimeSpan('1D')) - def test_TimeUnit_math(self): - time_unit_1 = TimeUnit('15m') - time_unit_2 = TimeUnit('15m_30s_3u') - time_unit_3 = TimeUnit(days=1) + def test_TimeSpan_math(self): - # Sum with other TimeUnit objects - self.assertEqual(str(time_unit_1+time_unit_2+time_unit_3), '1D_30m_30s_3u') + time_span_1 = TimeSpan('15m') + time_span_2 = TimeSpan('15m_30s_3u') + time_span_3 = TimeSpan(days=1) + + # Sum with other TimeSpan objects + self.assertEqual(str(time_span_1+time_span_2+time_span_3), '1D_30m_30s_3u') # Sum with datetime (also on DST change) - time_unit = TimeUnit('1h') + time_span = TimeSpan('1h') datetime1 = dt(2015,10,25,0,15,0, tz='Europe/Rome') - datetime2 = datetime1 + time_unit - datetime3 = datetime2 + time_unit - datetime4 = datetime3 + time_unit - datetime5 = datetime4 + time_unit + datetime2 = datetime1 + time_span + datetime3 = datetime2 + time_span + datetime4 = datetime3 + time_span + datetime5 = datetime4 + time_span self.assertEqual(str(datetime1), '2015-10-25 00:15:00+02:00') self.assertEqual(str(datetime2), '2015-10-25 01:15:00+02:00') @@ -559,29 +570,29 @@ def test_TimeUnit_math(self): self.assertEqual(str(datetime5), '2015-10-25 03:15:00+01:00') # Sum with a numerical value - time_unit = TimeUnit('1h') + time_span = TimeSpan('1h') epoch1 = 3600 - self.assertEqual(epoch1 + time_unit, 7200) + self.assertEqual(epoch1 + time_span, 7200) - # Subtract to other TimeUnit object + # Subtract to other TimeSpan object with self.assertRaises(NotImplementedError): - time_unit_1 - time_unit_2 + time_span_1 - time_span_2 # Subtract to a datetime object with self.assertRaises(NotImplementedError): - time_unit_1 - datetime1 + time_span_1 - datetime1 # In general, subtracting to anything is not implemented with self.assertRaises(NotImplementedError): - time_unit_1 - 'hello' + time_span_1 - 'hello' # Subtract from a datetime (also on DST change) - time_unit = TimeUnit('1h') + time_span = TimeSpan('1h') datetime1 = dt(2015,10,25,3,15,0, tz='Europe/Rome') - datetime2 = datetime1 - time_unit - datetime3 = datetime2 - time_unit - datetime4 = datetime3 - time_unit - datetime5 = datetime4 - time_unit + datetime2 = datetime1 - time_span + datetime3 = datetime2 - time_span + datetime4 = datetime3 - time_span + datetime5 = datetime4 - time_span self.assertEqual(str(datetime1), '2015-10-25 03:15:00+01:00') self.assertEqual(str(datetime2), '2015-10-25 02:15:00+01:00') @@ -590,236 +601,242 @@ def test_TimeUnit_math(self): self.assertEqual(str(datetime5), '2015-10-25 00:15:00+02:00') # Subtract from a numerical value - time_unit = TimeUnit('1h') + time_span = TimeSpan('1h') epoch1 = 7200 - self.assertEqual(epoch1 - time_unit, 3600) + self.assertEqual(epoch1 - time_span, 3600) # Test sum with Time - time_unit = TimeUnit('1h') + time_span = TimeSpan('1h') time = Time(60) - self.assertEqual((time+time_unit), 3660) + self.assertEqual((time+time_span), 3660) # Test equal - time_unit_1 = TimeUnit('15m') - self.assertEqual(time_unit_1, 900) - + time_span_1 = TimeSpan('15m') + self.assertEqual(time_span_1, 900) - def test_TimeUnit_types(self): - # Test type - self.assertEqual(TimeUnit('15m').type, TimeUnit._PHYSICAL) - self.assertEqual(TimeUnit('1h').type, TimeUnit._PHYSICAL) - self.assertEqual(TimeUnit('1D').type, TimeUnit._CALENDAR) - self.assertEqual(TimeUnit('1M').type, TimeUnit._CALENDAR) - - - def test_TimeUnit_duration(self): + def test_TimeSpan_duration(self): datetime1 = dt(2015,10,24,0,15,0, tz='Europe/Rome') datetime2 = dt(2015,10,25,0,15,0, tz='Europe/Rome') datetime3 = dt(2015,10,26,0,15,0, tz='Europe/Rome') - # Day unit - time_unit = TimeUnit('1D') + # Day span + time_span = TimeSpan('1D') with self.assertRaises(ValueError): - time_unit.as_seconds() - self.assertEqual(time_unit.as_seconds(datetime1), 86400) # No DST, standard day - self.assertEqual(time_unit.as_seconds(datetime2), 90000) # DST, change + time_span.as_seconds() + self.assertEqual(time_span.as_seconds(datetime1), 86400) # No DST, standard day + self.assertEqual(time_span.as_seconds(datetime2), 90000) # DST, change - # Week unit - time_unit = TimeUnit('1W') + # Week span + time_span = TimeSpan('1W') with self.assertRaises(ValueError): - time_unit.as_seconds() - self.assertEqual(time_unit.as_seconds(datetime1), (86400*7)+3600) - self.assertEqual(time_unit.as_seconds(datetime3), (86400*7)) + time_span.as_seconds() + self.assertEqual(time_span.as_seconds(datetime1), (86400*7)+3600) + self.assertEqual(time_span.as_seconds(datetime3), (86400*7)) - # Month Unit - time_unit = TimeUnit('1M') + # Month span + time_span = TimeSpan('1M') with self.assertRaises(ValueError): - time_unit.as_seconds() - self.assertEqual(time_unit.as_seconds(datetime3), (86400*31)) # October has 31 days so next month same day has 31 full days - self.assertEqual(time_unit.as_seconds(datetime1), ((86400*31)+3600)) # Same as above, but in this case we have a DST change in the middle + time_span.as_seconds() + self.assertEqual(time_span.as_seconds(datetime3), (86400*31)) # October has 31 days so next month same day has 31 full days + self.assertEqual(time_span.as_seconds(datetime1), ((86400*31)+3600)) # Same as above, but in this case we have a DST change in the middle - # Year Unit - time_unit = TimeUnit('1Y') + # Year span + time_span = TimeSpan('1Y') with self.assertRaises(ValueError): - time_unit.as_seconds() - self.assertEqual(time_unit.as_seconds(dt(2014,10,24,0,15,0, tz='Europe/Rome')), (86400*365)) # Standard year - self.assertEqual(time_unit.as_seconds(dt(2015,10,24,0,15,0, tz='Europe/Rome')), (86400*366)) # Leap year + time_span.as_seconds() + self.assertEqual(time_span.as_seconds(dt(2014,10,24,0,15,0, tz='Europe/Rome')), (86400*365)) # Standard year + self.assertEqual(time_span.as_seconds(dt(2015,10,24,0,15,0, tz='Europe/Rome')), (86400*366)) # Leap year # Test duration with composite seconds init - self.assertEqual(TimeUnit(minutes=1, seconds=3).as_seconds(), 63) + self.assertEqual(TimeSpan(minutes=1, seconds=3).as_seconds(), 63) - def test_TimeUnit_shift(self): + def test_TimeSpan_shift(self): datetime1 = dt(2015,10,24,0,15,0, tz='Europe/Rome') datetime2 = dt(2015,10,25,0,15,0, tz='Europe/Rome') datetime3 = dt(2015,10,26,0,15,0, tz='Europe/Rome') - # Day unit - time_unit = TimeUnit('1D') - self.assertEqual(time_unit.shift(datetime1), dt(2015,10,25,0,15,0, tz='Europe/Rome')) # No DST, standard day - self.assertEqual(time_unit.shift(datetime2), dt(2015,10,26,0,15,0, tz='Europe/Rome')) # DST, change + # Day span + time_span = TimeSpan('1D') + self.assertEqual(time_span.shift(datetime1), dt(2015,10,25,0,15,0, tz='Europe/Rome')) # No DST, standard day + self.assertEqual(time_span.shift(datetime2), dt(2015,10,26,0,15,0, tz='Europe/Rome')) # DST, change - # Day unit on not-existent hour due to DST + # Day span on not-existent hour due to DST starting_dt = dt(2023,3,25,2,15, tz='Europe/Rome') with self.assertRaises(ValueError): - starting_dt + TimeUnit('1D') + starting_dt + TimeSpan('1D') - # Day unit on ambiguous hour due to DST + # Day span on ambiguous hour due to DST starting_dt = dt(2023,10,28,2,15, tz='Europe/Rome') with self.assertRaises(ValueError): - starting_dt + TimeUnit('1D') + starting_dt + TimeSpan('1D') - # Week unit - time_unit = TimeUnit('1W') - self.assertEqual(time_unit.shift(datetime1), dt(2015,10,31,0,15,0, tz='Europe/Rome')) - self.assertEqual(time_unit.shift(datetime3), dt(2015,11,2,0,15,0, tz='Europe/Rome')) + # Week span + time_span = TimeSpan('1W') + self.assertEqual(time_span.shift(datetime1), dt(2015,10,31,0,15,0, tz='Europe/Rome')) + self.assertEqual(time_span.shift(datetime3), dt(2015,11,2,0,15,0, tz='Europe/Rome')) - # Month Unit - time_unit = TimeUnit('1M') - self.assertEqual(time_unit.shift(datetime1), dt(2015,11,24,0,15,0, tz='Europe/Rome')) - self.assertEqual(time_unit.shift(datetime2), dt(2015,11,25,0,15,0, tz='Europe/Rome')) - self.assertEqual(time_unit.shift(datetime3), dt(2015,11,26,0,15,0, tz='Europe/Rome')) + # Month span + time_span = TimeSpan('1M') + self.assertEqual(time_span.shift(datetime1), dt(2015,11,24,0,15,0, tz='Europe/Rome')) + self.assertEqual(time_span.shift(datetime2), dt(2015,11,25,0,15,0, tz='Europe/Rome')) + self.assertEqual(time_span.shift(datetime3), dt(2015,11,26,0,15,0, tz='Europe/Rome')) # Test 12%12 must give 12 edge case - self.assertEqual(time_unit.shift(dt(2015,1,1,0,0,0, tz='Europe/Rome')), dt(2015,2,1,0,0,0, tz='Europe/Rome')) - self.assertEqual(time_unit.shift(dt(2015,11,1,0,0,0, tz='Europe/Rome')), dt(2015,12,1,0,0,0, tz='Europe/Rome')) + self.assertEqual(time_span.shift(dt(2015,1,1,0,0,0, tz='Europe/Rome')), dt(2015,2,1,0,0,0, tz='Europe/Rome')) + self.assertEqual(time_span.shift(dt(2015,11,1,0,0,0, tz='Europe/Rome')), dt(2015,12,1,0,0,0, tz='Europe/Rome')) - # Year Unit - time_unit = TimeUnit('1Y') - self.assertEqual(time_unit.shift(datetime1), dt(2016,10,24,0,15,0, tz='Europe/Rome')) + # Year span + time_span = TimeSpan('1Y') + self.assertEqual(time_span.shift(datetime1), dt(2016,10,24,0,15,0, tz='Europe/Rome')) - def test_TimeUnit_operations(self): + def test_TimeSpan_operations(self): - # Test that complex time_units are not handable - time_unit = TimeUnit('1D_3h_5m') + # Test that complex time_spans are not handable + time_span = TimeSpan('1D_3h_5m') datetime = dt(2015,1,1,16,37,14, tz='Europe/Rome') with self.assertRaises(ValueError): - _ = time_unit.floor(datetime) + _ = time_span.floor(datetime) # Test in ceil/floor/round normal conditions (days) - time_unit = TimeUnit('1D') - self.assertEqual(time_unit.ceil(dt(2023,11,25,10,0,0, tz='Europe/Rome')), time_unit.round(dt(2023,11,26,0,0,0, tz='Europe/Rome'))) - self.assertEqual(time_unit.floor(dt(2023,11,25,19,0,0, tz='Europe/Rome')), time_unit.round(dt(2023,11,25,0,0,0, tz='Europe/Rome'))) - self.assertEqual(time_unit.round(dt(2023,11,25,10,0,0, tz='Europe/Rome')), time_unit.round(dt(2023,11,25,0,0,0, tz='Europe/Rome'))) - self.assertEqual(time_unit.round(dt(2023,11,25,12,0,0, tz='Europe/Rome')), time_unit.round(dt(2023,11,25,0,0,0, tz='Europe/Rome'))) - self.assertEqual(time_unit.round(dt(2023,11,25,13,0,0, tz='Europe/Rome')), time_unit.round(dt(2023,11,26,0,0,0, tz='Europe/Rome'))) + time_span = TimeSpan('1D') + self.assertEqual(time_span.ceil(dt(2023,11,25,10,0,0, tz='Europe/Rome')), time_span.round(dt(2023,11,26,0,0,0, tz='Europe/Rome'))) + self.assertEqual(time_span.floor(dt(2023,11,25,19,0,0, tz='Europe/Rome')), time_span.round(dt(2023,11,25,0,0,0, tz='Europe/Rome'))) + self.assertEqual(time_span.round(dt(2023,11,25,10,0,0, tz='Europe/Rome')), time_span.round(dt(2023,11,25,0,0,0, tz='Europe/Rome'))) + self.assertEqual(time_span.round(dt(2023,11,25,12,0,0, tz='Europe/Rome')), time_span.round(dt(2023,11,25,0,0,0, tz='Europe/Rome'))) + self.assertEqual(time_span.round(dt(2023,11,25,13,0,0, tz='Europe/Rome')), time_span.round(dt(2023,11,26,0,0,0, tz='Europe/Rome'))) # Test in ceil/floor/round across DST change (days) - time_unit = TimeUnit('1D') - self.assertEqual(time_unit.ceil(dt(2023,3,26,10,0,0, tz='Europe/Rome')), time_unit.round(dt(2023,3,27,0,0,0, tz='Europe/Rome'))) - self.assertEqual(time_unit.floor(dt(2023,3,26,19,0,0, tz='Europe/Rome')), time_unit.round(dt(2023,3,26,0,0,0, tz='Europe/Rome'))) - self.assertEqual(time_unit.round(dt(2023,3,26,12,30,0, tz='Europe/Rome')), time_unit.round(dt(2023,3,26,0,0,0, tz='Europe/Rome'))) - self.assertEqual(time_unit.round(dt(2023,3,26,12,31,0, tz='Europe/Rome')), time_unit.round(dt(2023,3,27,0,0,0, tz='Europe/Rome'))) + time_span = TimeSpan('1D') + self.assertEqual(time_span.ceil(dt(2023,3,26,10,0,0, tz='Europe/Rome')), time_span.round(dt(2023,3,27,0,0,0, tz='Europe/Rome'))) + self.assertEqual(time_span.floor(dt(2023,3,26,19,0,0, tz='Europe/Rome')), time_span.round(dt(2023,3,26,0,0,0, tz='Europe/Rome'))) + self.assertEqual(time_span.round(dt(2023,3,26,12,30,0, tz='Europe/Rome')), time_span.round(dt(2023,3,26,0,0,0, tz='Europe/Rome'))) + self.assertEqual(time_span.round(dt(2023,3,26,12,31,0, tz='Europe/Rome')), time_span.round(dt(2023,3,27,0,0,0, tz='Europe/Rome'))) # Test in ceil/floor/round normal conditions (hours) - time_unit = TimeUnit('1h') + time_span = TimeSpan('1h') datetime = dt(2015,1,1,16,37,14, tz='Europe/Rome') - self.assertEqual(time_unit.floor(datetime), dt(2015,1,1,16,0,0, tz='Europe/Rome')) - self.assertEqual(time_unit.ceil(datetime), dt(2015,1,1,17,0,0, tz='Europe/Rome')) + self.assertEqual(time_span.floor(datetime), dt(2015,1,1,16,0,0, tz='Europe/Rome')) + self.assertEqual(time_span.ceil(datetime), dt(2015,1,1,17,0,0, tz='Europe/Rome')) # Test in ceil/floor/round normal conditions (minutes) - time_unit = TimeUnit('15m') + time_span = TimeSpan('15m') datetime = dt(2015,1,1,16,37,14, tz='Europe/Rome') - self.assertEqual(time_unit.floor(datetime), dt(2015,1,1,16,30,0, tz='Europe/Rome')) - self.assertEqual(time_unit.ceil(datetime), dt(2015,1,1,16,45,0, tz='Europe/Rome')) + self.assertEqual(time_span.floor(datetime), dt(2015,1,1,16,30,0, tz='Europe/Rome')) + self.assertEqual(time_span.ceil(datetime), dt(2015,1,1,16,45,0, tz='Europe/Rome')) # Test ceil/floor/round in normal conditions (seconds) - time_unit = TimeUnit('30s') + time_span = TimeSpan('30s') datetime = dt(2015,1,1,16,37,14, tz='Europe/Rome') - self.assertEqual(time_unit.floor(datetime), dt(2015,1,1,16,37,0, tz='Europe/Rome')) - self.assertEqual(time_unit.ceil(datetime), dt(2015,1,1,16,37,30, tz='Europe/Rome')) + self.assertEqual(time_span.floor(datetime), dt(2015,1,1,16,37,0, tz='Europe/Rome')) + self.assertEqual(time_span.ceil(datetime), dt(2015,1,1,16,37,30, tz='Europe/Rome')) # Test ceil/floor/round across 1970-1-1 (minutes) - time_unit = TimeUnit('5m') + time_span = TimeSpan('5m') datetime1 = dt(1969,12,31,23,57,29, tz='UTC') # epoch = -3601 datetime2 = dt(1969,12,31,23,59,59, tz='UTC') # epoch = -3601 - self.assertEqual(time_unit.floor(datetime1), dt(1969,12,31,23,55,0, tz='UTC')) - self.assertEqual(time_unit.ceil(datetime1), dt(1970,1,1,0,0, tz='UTC')) - self.assertEqual(time_unit.round(datetime1), dt(1969,12,31,23,55,0, tz='UTC')) - self.assertEqual(time_unit.round(datetime2), dt(1970,1,1,0,0, tz='UTC')) + self.assertEqual(time_span.floor(datetime1), dt(1969,12,31,23,55,0, tz='UTC')) + self.assertEqual(time_span.ceil(datetime1), dt(1970,1,1,0,0, tz='UTC')) + self.assertEqual(time_span.round(datetime1), dt(1969,12,31,23,55,0, tz='UTC')) + self.assertEqual(time_span.round(datetime2), dt(1970,1,1,0,0, tz='UTC')) # Test ceil/floor/round (3 hours-test) - time_unit = TimeUnit('3h') + time_span = TimeSpan('3h') datetime = dt(1969,12,31,23,0,1, tz='Europe/Rome') # negative epoch - self.assertEqual(time_unit.floor(datetime), dt(1969,12,31,23,0,0, tz='Europe/Rome')) - self.assertEqual(time_unit.ceil(datetime), dt(1970,1,1,2,0, tz='Europe/Rome')) + self.assertEqual(time_span.floor(datetime), dt(1969,12,31,23,0,0, tz='Europe/Rome')) + self.assertEqual(time_span.ceil(datetime), dt(1970,1,1,2,0, tz='Europe/Rome')) # Test ceil/floor/round across 1970-1-1 (together with the 2 hours-test, TODO: decouple) - time_unit = TimeUnit('2h') + time_span = TimeSpan('2h') datetime1 = dt(1969,12,31,22,59,59, tz='Europe/Rome') # negative epoch datetime2 = dt(1969,12,31,23,0,1, tz='Europe/Rome') # negative epoch - self.assertEqual(time_unit.floor(datetime1), dt(1969,12,31,22,0,0, tz='Europe/Rome')) - self.assertEqual(time_unit.ceil(datetime1), dt(1970,1,1,0,0, tz='Europe/Rome')) - self.assertEqual(time_unit.round(datetime1), dt(1969,12,31,22,0, tz='Europe/Rome')) - self.assertEqual(time_unit.round(datetime2), dt(1970,1,1,0,0, tz='Europe/Rome')) + self.assertEqual(time_span.floor(datetime1), dt(1969,12,31,22,0,0, tz='Europe/Rome')) + self.assertEqual(time_span.ceil(datetime1), dt(1970,1,1,0,0, tz='Europe/Rome')) + self.assertEqual(time_span.round(datetime1), dt(1969,12,31,22,0, tz='Europe/Rome')) + self.assertEqual(time_span.round(datetime2), dt(1970,1,1,0,0, tz='Europe/Rome')) # Test ceil/floor/round across DST change (hours) - time_unit = TimeUnit('1h') + time_span = TimeSpan('1h') datetime1 = dt(2015,10,25,0,15,0, tz='Europe/Rome') - datetime2 = datetime1 + time_unit # 2015-10-25 01:15:00+02:00 - datetime3 = datetime2 + time_unit # 2015-10-25 02:15:00+02:00 - datetime4 = datetime3 + time_unit # 2015-10-25 02:15:00+01:00 + datetime2 = datetime1 + time_span # 2015-10-25 01:15:00+02:00 + datetime3 = datetime2 + time_span # 2015-10-25 02:15:00+02:00 + datetime4 = datetime3 + time_span # 2015-10-25 02:15:00+01:00 datetime1_rounded = dt(2015,10,25,0,0,0, tz='Europe/Rome') - datetime2_rounded = datetime1_rounded + time_unit - datetime3_rounded = datetime2_rounded + time_unit - datetime4_rounded = datetime3_rounded + time_unit - datetime5_rounded = datetime4_rounded + time_unit + datetime2_rounded = datetime1_rounded + time_span + datetime3_rounded = datetime2_rounded + time_span + datetime4_rounded = datetime3_rounded + time_span + datetime5_rounded = datetime4_rounded + time_span - self.assertEqual(time_unit.floor(datetime2), datetime2_rounded) - self.assertEqual(time_unit.ceil(datetime2), datetime3_rounded) + self.assertEqual(time_span.floor(datetime2), datetime2_rounded) + self.assertEqual(time_span.ceil(datetime2), datetime3_rounded) - self.assertEqual(time_unit.floor(datetime3), datetime3_rounded) - self.assertEqual(time_unit.ceil(datetime3), datetime4_rounded) + self.assertEqual(time_span.floor(datetime3), datetime3_rounded) + self.assertEqual(time_span.ceil(datetime3), datetime4_rounded) - self.assertEqual(time_unit.floor(datetime4), datetime4_rounded) - self.assertEqual(time_unit.ceil(datetime4), datetime5_rounded) + self.assertEqual(time_span.floor(datetime4), datetime4_rounded) + self.assertEqual(time_span.ceil(datetime4), datetime5_rounded) - # Test ceil/floor/round with a calendar time unit and across a DST change + # Test ceil/floor/round with a calendar time span and across a DST change - # Day unit - time_unit = TimeUnit('1D') + # Day span + time_span = TimeSpan('1D') datetime1 = dt(2015,10,25,4,15,34, tz='Europe/Rome') # DST off (+01:00) datetime1_floor = dt(2015,10,25,0,0,0, tz='Europe/Rome') # DST on (+02:00) datetime1_ceil = dt(2015,10,26,0,0,0, tz='Europe/Rome') # DST off (+01:00) - self.assertEqual(time_unit.floor(datetime1), datetime1_floor) - self.assertEqual(time_unit.ceil(datetime1), datetime1_ceil) + self.assertEqual(time_span.floor(datetime1), datetime1_floor) + self.assertEqual(time_span.ceil(datetime1), datetime1_ceil) - # Week unit - time_unit = TimeUnit('1W') + # Week span + time_span = TimeSpan('1W') datetime1 = dt(2023,10,29,15,47, tz='Europe/Rome') # DST off (+01:00) datetime1_floor = dt(2023,10,23,0,0, tz='Europe/Rome') # DST on (+02:00) datetime1_ceil = dt(2023,10,30,0,0, tz='Europe/Rome') # DST off (+01:00) - self.assertEqual(time_unit.floor(datetime1), datetime1_floor) - self.assertEqual(time_unit.ceil(datetime1), datetime1_ceil) + self.assertEqual(time_span.floor(datetime1), datetime1_floor) + self.assertEqual(time_span.ceil(datetime1), datetime1_ceil) - # Month unit - time_unit = TimeUnit('1M') + # Month span + time_span = TimeSpan('1M') datetime1 = dt(2015,10,25,4,15,34, tz='Europe/Rome') # DST off (+01:00) datetime1_floor = dt(2015,10,1,0,0,0, tz='Europe/Rome') # DST on (+02:00) datetime1_ceil = dt(2015,11,1,0,0,0, tz='Europe/Rome') # DST off (+01:00) - self.assertEqual(time_unit.floor(datetime1), datetime1_floor) - self.assertEqual(time_unit.ceil(datetime1), datetime1_ceil) + self.assertEqual(time_span.floor(datetime1), datetime1_floor) + self.assertEqual(time_span.ceil(datetime1), datetime1_ceil) - # Year unit - time_unit = TimeUnit('1Y') + # Year span + time_span = TimeSpan('1Y') datetime1 = dt(2015,10,25,4,15,34, tz='Europe/Rome') datetime1_floor = dt(2015,1,1,0,0,0, tz='Europe/Rome') datetime1_ceil = dt(2016,1,1,0,0,0, tz='Europe/Rome') - self.assertEqual(time_unit.floor(datetime1), datetime1_floor) - self.assertEqual(time_unit.ceil(datetime1), datetime1_ceil) + self.assertEqual(time_span.floor(datetime1), datetime1_floor) + self.assertEqual(time_span.ceil(datetime1), datetime1_ceil) + + # Lastly ensure everything works with Time as well: + TimeSpan('1D').round(Time(2023,10,29,16,0,0)) + TimeSpan('1D').ceil(Time(2023,10,29,16,0,0)) + TimeSpan('1D').floor(Time(2023,10,29,16,0,0)) + TimeSpan('1D').shift(Time(2023,10,29,16,0,0)) + + # Test the "slider" + start = Time(2023,10,29,0,0,0, tz='Europe/Rome') + end = start + TimeSpan('1D') + + slider = start + while slider < end: + slider = slider + TimeSpan('1h') + self.assertEqual(slider, end) diff --git a/propertime/time.py b/propertime/time.py index f5a3b7d..645c8c5 100644 --- a/propertime/time.py +++ b/propertime/time.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""Time and TimeUnit classes""" +"""Time and TimeSpan classes""" import re import math @@ -81,7 +81,7 @@ def from_dt(cls, dt, tz='auto', offset='auto', guessing=False): embedded_tz = dt.tzinfo # Handle naive datetime or extract time zone/offset - if not (embedded_offset or embedded_tz): + if embedded_offset is None and embedded_tz is None: # Look at the tz argument if any, and decorate if given_tz is not None: @@ -95,19 +95,19 @@ def from_dt(cls, dt, tz='auto', offset='auto', guessing=False): else: raise ValueError('Got a naive datetime, please set its time zone or offset') - # Check for potential ambiguity - if is_dt_ambiguous_without_offset(dt): - dt_naive = dt.replace(tzinfo=None) - if not guessing: - raise ValueError('Sorry, datetime {} is ambiguous on time zone {} without an offset'.format(dt_naive, dt.tzinfo)) - else: - # TODO: move to a _get_utc_offset() support function. Used also in Time __str__ and dt() in utilities - iso_time_part = str_from_dt(dt).split('T')[1] - if '+' in iso_time_part: - offset_assumed = '+'+iso_time_part.split('+')[1] + # Check for potential ambiguity + if is_dt_ambiguous_without_offset(dt): + dt_naive = dt.replace(tzinfo=None) + if not guessing: + raise ValueError('Sorry, datetime {} is ambiguous on time zone {} without an offset'.format(dt_naive, dt.tzinfo)) else: - offset_assumed = '-'+iso_time_part.split('-')[1] - logger.warning('Time {} is ambiguous on time zone {}, assuming {} UTC offset'.format(dt_naive, tz, offset_assumed)) + # TODO: move to a _get_utc_offset() support function. Used also in Time __str__ and dt() in utilities + iso_time_part = str_from_dt(dt).split('T')[1] + if '+' in iso_time_part: + offset_assumed = '+'+iso_time_part.split('+')[1] + else: + offset_assumed = '-'+iso_time_part.split('-')[1] + logger.warning('Time {} is ambiguous on time zone {}, assuming {} UTC offset'.format(dt_naive, tz, offset_assumed)) # Now convert the (always time zone or offset -aware) datetime to seconds s = s_from_dt(dt) @@ -318,11 +318,15 @@ def __new__(cls, *args, tz='UTC', offset='auto', guessing=False): # Handle time zone and offset if tz is not None: - # Set the time zone and compute the time zone out from the time zone + # Set the time zone and compute the offset from the time zone time_instance._tz = tz _dt = dt_from_s(s, tz=time_instance._tz) - sign = -1 if _dt.utcoffset().days < 0 else 1 - time_instance._offset = sign * _dt.utcoffset().seconds + sign = -1 if _dt.utcoffset().days < 0 else 1 + if sign >0: + _offset = _dt.utcoffset().seconds + else: + _offset = -((24*3600) - _dt.utcoffset().seconds) + time_instance._offset = _offset # If there was also an offset set, check consistency: if offset != 'auto': @@ -440,14 +444,18 @@ def real(self): raise NotImplementedError('It does not make sense to use imaginary numbers with time') -class TimeUnit: - """A time unit object, that can have both fixed (physical) or variable (calendar) time length. - It can handle precision up to the microsecond and can be added and subtracted with numerical - values, Time and datetime objects, and other TimeUnits. +class TimeSpan: + """A time span, that can have both fixed and variable time length (duration). Whether this + is variable or not, it depends if there are any calendar time components involved (years, + months, weeks and days). + + Time spans support many operations, and can be added and subtracted with numerical values, Time and + datetime objects, and other time spans. + + Their initialization supports both string representations and explicitly setting the various + components: years, months, weeks, days, hours, minutes, seconds and microseconds. - Can be initialized both using a numerical value, a string representation, or by explicitly setting - years, months, weeks, days, hours, minutes, seconds and microseconds. In the string representation, - the mapping is as follows: + In the string representation, the mapping is as follows: * ``'Y': 'years'`` * ``'M': 'months'`` @@ -458,25 +466,78 @@ class TimeUnit: * ``'s': 'seconds'`` * ``'u': 'microseconds'`` - For example, to create a time unit of one hour, the following three are equivalent, where the - first one uses the numerical value, the second the string representation, and the third explicitly - sets the time component (hours in this case): ``TimeUnit('1h')``, ``TimeUnit(hours=1)``, or ``TimeUnit(3600)``. - Not all time units can be initialized using the numerical value, in particular calendar time units which can - have variable duration: a time unit of one day, or ``TimeUnit('1D')``, can last for 23, 24 or 24 hours depending - on DST changes. On the contrary, a ``TimeUnit('24h')`` will always last 24 hours and can be initialized as - ``TimeUnit(86400)`` as well. + For example, to create a time span of one hour, the following four are equivalent: + + .. code-block:: python + + TimeSpan('1h') == TimeSpan('3600s') == TimeSpan(seconds=3600) == TimeSpan(hours=1) + + The first two use the string representation, while the third and the fourth explicitly + set its components (hours and seconds, in this case). + + To get the time span length, or duration, you can use the ``as_seconds()`` method: + + .. code-block:: python + + TimeSpan('1h').as_seconds() + + However, as soon as a calendar component kicks in, the time span length becomes variable: a time span of one + day can last for 23, 24 or 24 hours (and thus 82800, 86400 and 90000 seconds) depending on DST changes. + Similarly, a month can have 28, 29, 30 or 31 days; and a year can have both 365 and 366 days. + + Note indeed that: + + .. code-block:: python + + TimeSpan('24h') != TimeSpan('1D') + + The length of a time span where one ore more calendar components are involved is therefore well defined only if + providing context about *when* it is applied: + + .. code-block:: python + + TimeSpan('1D').as_seconds(starting_at=some_time) + + This is automatically handled when using time spans to perform operations, so that for example to get to tomorow's + same time of the day, you would just do: + + .. code-block:: python + + Time() + TimeSpan('1D') + + ...and the time span will take care of computing the correct calendar arithmetic, including the DST change. + If you instead wanted to add exactly 24 hours, thus getting to a different time of the day if DST changed + within the time span, you would have used: + + .. code-block:: python + + Time() + TimeSpan('24h') + + Lastly, when calendar components are involved, there might also be some undefined or ambiguous operations. + And exactly as it would happen if dividing a number by zero, they will cause an error: + + .. code-block:: python + + Time(2023,1,31) + TimeSpan(months=1) + # Error, the 31st of February does not exist + + Time(2023,3,25,2,15,0, tz='Europe/Rome') + TimeSpan(days=1) + # Error, the 2:15 AM does not exist on Europe/Rome in the target day + + Time(2023,10,28,2,15,0, tz='Europe/Rome') + TimeSpan(days=1) + # Error, there are two 2:15 AM on Europe/Rome in the target day + Args: - value: the time unit value, either as seconds (float) or string representation according to the mapping above. - years: the time unit years component. - weeks: the time unit weeks component. - months: the time unit weeks component. - days: the time unit days component. - hours: the time unit hours component. - minutes: the time unit minutes component. - seconds: the time unit seconds component. - microseconds: the time unit microseconds component. - trustme: a boolean switch to skip checks. + value (:obj:`str`, :obj:`tzinfo`): the time span value as string representation. + years (:obj:`int`): the time span years component. + weeks (:obj:`int`): the time span weeks component. + months (:obj:`int`): the time span weeks component. + days(:obj:`int`): the time span days component. + hours (:obj:`int`): the time span hours component. + minutes (:obj:`int`): the time span minutes component. + seconds (:obj:`int`, :obj:`float`): the time span seconds, possibly as float to include sub-second precision (up to the microsecond). + microseconds(:obj:`int`): the time span microseconds component. """ _CALENDAR = 'Calendar' @@ -496,36 +557,20 @@ class TimeUnit: 'u': 'microseconds' } - def __init__(self, value=None, years=0, weeks=0, months=0, days=0, hours=0, minutes=0, seconds=0, microseconds=0, trustme=False): + def __init__(self, value=None, years=0, weeks=0, months=0, days=0, hours=0, minutes=0, seconds=0, microseconds=0): - if not trustme: + # Value OR explicit time components + if value and (years or months or days or hours or minutes or seconds or microseconds): + raise ValueError('Choose between string init and explicit setting of years, months, days, hours etc.') - if value: - if is_numerical(value): - string = '{}s'.format(value) - else: - if not isinstance(value, str): - raise TypeError('TimeUnits must be initialized with a number, a string or explicitly setting years, months, days, hours etc. (Got "{}")'.format(string.__class__.__name__)) - string = value - else: - string = None - - # Value OR explicit time components - if value and (years or months or days or hours or minutes or seconds or microseconds): - raise ValueError('Choose between string/numerical init and explicit setting of years, months, days, hours etc.') - - # Check types: - if not isinstance(years, int): raise ValueError('year not of type int (got "{}")'.format(years.__class__.__name__)) - if not isinstance(weeks, int): raise ValueError('weeks not of type int (got "{}")'.format(weeks.__class__.__name__)) - if not isinstance(months, int): raise ValueError('months not of type int (got "{}")'.format(months.__class__.__name__)) - if not isinstance(days, int): raise ValueError('days not of type int (got "{}")'.format(days.__class__.__name__)) - if not isinstance(hours, int): raise ValueError('hours not of type int (got "{}")'.format(hours.__class__.__name__)) - if not isinstance(minutes, int): raise ValueError('minutes not of type int (got "{}")'.format(minutes.__class__.__name__)) - if not isinstance(seconds, int): raise ValueError('seconds not of type int (got "{}")'.format(seconds.__class__.__name__)) - if not isinstance(microseconds, int): raise ValueError('microseconds not of type int (got "{}")'.format(microseconds.__class__.__name__)) - - # Set the time components if given - # TODO: set them only if given? + # Special case for second/microsecond (TODO: improve/rethink me) + if isinstance(seconds, float): + if microseconds: + raise ValueError('Choose between seconds as float or to use microseconds, got both.') + microseconds = int((seconds-int(math.floor(seconds)))*1000000) + seconds = int(math.floor(seconds)) + + # Set the time components self.years = years self.months = months self.weeks = weeks @@ -535,53 +580,56 @@ def __init__(self, value=None, years=0, weeks=0, months=0, days=0, hours=0, minu self.seconds = seconds self.microseconds = microseconds - if string: + # Handle string value + if value: + if isinstance(value,str): - # Specific case for floating point seconds (TODO: improve me, maybe inlclude it in the regex?) - if string.endswith('s') and '.' in string: - if '_' in string: - raise NotImplementedError('Composite TimeUnits with floating point seconds not yet implemented.') - self.seconds = int(string.split('.')[0]) + # Parse the value (as string), either as special case for floating point seconds + # or using the regex (TODO: improve/rethink me, maybe include everything in the regex) + if value.endswith('s') and '.' in value: - # Get decimal seconds as string - decimal_seconds_str = string.split('.')[1][0:-1] # Remove the last "s" + if '_' in value: + raise NotImplementedError('Composite TimeSpans with floating point seconds not yet implemented.') + self.seconds = int(value.split('.')[0]) - # Ensure we can handle precision - if len(decimal_seconds_str) > 6: - decimal_seconds_str = decimal_seconds_str[0:6] - #raise ValueError('Sorry, "{}" has too many decimal seconds to be handled with a TimeUnit (which supports up to the microsecond).'.format(string)) + # Get decimal seconds as value + decimal_seconds_str = value.split('.')[1][0:-1] # Remove the last "s" - # Add missing trailing zeros - missing_trailing_zeros = 6-len(decimal_seconds_str) - for _ in range(missing_trailing_zeros): - decimal_seconds_str += '0' + # Ensure we can handle precision + if len(decimal_seconds_str) > 6: + decimal_seconds_str = decimal_seconds_str[0:6] + #raise ValueError('Sorry, "{}" has too many decimal seconds to be handled with a TimeSpan (which supports up to the microsecond).'.format(value)) - # Cast to int & set - self.microseconds = int(decimal_seconds_str) + # Add missing trailing zeros + missing_trailing_zeros = 6-len(decimal_seconds_str) + for _ in range(missing_trailing_zeros): + decimal_seconds_str += '0' - else: + # Cast to int & set + self.microseconds = int(decimal_seconds_str) - # Parse string using regex - self.strings = string.split("_") - regex = re.compile('^([0-9]+)([YMDWhmsu]{1,2})$') + else: - for string in self.strings: - try: - groups = regex.match(string).groups() - except AttributeError: - raise ValueError('Cannot parse string representation for the TimeUnit, unknown format ("{}")'.format(string)) from None + # Parse value using regex + self.values = value.split("_") + regex = re.compile('^([0-9]+)([YMDWhmsu]{1,2})$') - setattr(self, self._mapping_table[groups[1]], int(groups[0])) + for value in self.values: + try: + groups = regex.match(value).groups() + except AttributeError: + raise ValueError('Cannot parse string representation for the TimeSpan, unknown format ("{}")'.format(value)) from None - if not trustme: + setattr(self, self._mapping_table[groups[1]], int(groups[0])) - # If nothing set, raise error - if not self.years and not self.weeks and not self.months and not self.days and not self.hours and not self.minutes and not self.seconds and not self.microseconds: - raise ValueError('Detected zero-duration TimeUnit!') + if not self.years and not self.months and not self.weeks and not self.days and not self.hours and not self.minutes and not self.seconds and not self.microseconds: + raise ValueError('Dont\'t know hot to create a TimeSpan from value "{}" of type {}'.format(value, value.__class__.__name__)) + else: + raise ValueError('Dont\'t know hot to create a TimeSpan from value "{}" of type {}'.format(value, value.__class__.__name__)) @property def value(self): - """The value of the TimeUnit, as its string representation.""" + """The value of the TimeSpan, as its string representation.""" return(str(self)) def __repr__(self): @@ -601,7 +649,7 @@ def __repr__(self): def __add__(self, other): if isinstance(other, self.__class__): - return TimeUnit(years = self.years + other.years, + return TimeSpan(years = self.years + other.years, months = self.months + other.months, weeks = self.weeks + other.weeks, days = self.days + other.days, @@ -622,7 +670,7 @@ def __add__(self, other): return other + self.as_seconds() else: - raise NotImplementedError('Adding TimeUnits with objects of class "{}" is not implemented'.format(other.__class__.__name__)) + raise NotImplementedError('Adding TimeSpans with objects of class "{}" is not implemented'.format(other.__class__.__name__)) def __radd__(self, other): return self.__add__(other) @@ -630,7 +678,7 @@ def __radd__(self, other): def __rsub__(self, other): if isinstance(other, self.__class__): - raise NotImplementedError('Subracting a TimeUnit from another TimeUnit is not implemented to prevent negative TimeUnits.') + raise NotImplementedError('Subracting a TimeSpan from another TimeSpan is not implemented to prevent negative TimeSpans.') elif isinstance(other, datetime): if not other.tzinfo: @@ -644,61 +692,69 @@ def __rsub__(self, other): return other - self.as_seconds() else: - raise NotImplementedError('Subracting TimeUnits with objects of class "{}" is not implemented'.format(other.__class__.__name__)) + raise NotImplementedError('Subtracting TimeSpans with objects of class "{}" is not implemented'.format(other.__class__.__name__)) def __sub__(self, other): - raise NotImplementedError('Cannot subtract anything from a TimeUnit. Only a TimeUnit from something else.') + raise NotImplementedError('Cannot subtract anything from a TimeSpan. Only a TimeSpan from something else.') def __truediv__(self, other): - raise NotImplementedError('Division for TimeUnits is not implemented') + raise NotImplementedError('Division for TimeSpans is not implemented') def __rtruediv__(self, other): - raise NotImplementedError('Division for TimeUnits is not implemented') + raise NotImplementedError('Division for TimeSpans is not implemented') def __mul__(self, other): - raise NotImplementedError('Multiplication for TimeUnits is not implemented') + raise NotImplementedError('Multiplication for TimeSpans is not implemented') def __rmul__(self, other): return self.__mul__(other) def __eq__(self, other): - # Check against another TimeUnit - if isinstance(other, TimeUnit): - if self.is_calendar() and other.is_calendar(): - # Check using the calendar components - if self.years != other.years: - return False - if self.months != other.months: - return False - if self.weeks != other.weeks: - return False - if self.days != other.days: - return False - if self.hours != other.hours: - return False - if self.minutes != other.minutes: - return False - if self.seconds != other.seconds: - return False - if self.microseconds != other.microseconds: + # Check against another TimeSpan + if isinstance(other, TimeSpan): + + try: + # First of all check with the duration as seconds + if self.as_seconds() == other.as_seconds(): + return True + else: return False - return True - elif self.is_calendar() and not other.is_calendar(): + except: + pass + + # Check using the calendar components + if self.years != other.years: return False - elif not self.is_calendar() and other.is_calendar(): + if self.months != other.months: return False - else: - # Check using the duration in seconds, as 15m and 900s are actually the same unit + if self.weeks != other.weeks: + return False + if self.days != other.days: + return False + if self.hours != other.hours: + return False + if self.minutes != other.minutes: + return False + if self.seconds != other.seconds: + return False + if self.microseconds != other.microseconds: + return False + return True + + # Check using the duration in seconds (if defined), as 15m and 900s are actually the same span + try: if self.as_seconds() == other.as_seconds(): return True + except ValueError: + pass # Check for direct equality with value, i.e. comparing with a string if self.value == other: return True - # Check for equality on the same "registered" value - if isinstance(other, TimeUnit): + # Check for equality on the same value + if isinstance(other, TimeSpan): if self.value == other.value: return True @@ -707,8 +763,6 @@ def __eq__(self, other): if self.as_seconds() == other: return True except (TypeError, ValueError): - # Raised if this or the other other TimeUnit is of calendar type, e.g. - # ValueError: You can ask to get a calendar TimeUnit as seconds only if you provide the unit starting point pass # If everything fails, return false: @@ -721,11 +775,11 @@ def _is_composite(self): return True if types > 1 else False @property - def type(self): - """The type of the TimeUnit. + def __type(self): + """The type of the TimeSpan. - - "Physical" if based on hours, minutes, seconds and microseconds, which have fixed duration. - - "Calendar" if based on years, months, weeks and days, which have variable duration depending on the starting date, + - "Physical" if based only on hours, minutes, seconds and microseconds, which have fixed duration. + - "Calendar" if also based on years, months, weeks and days, which have variable duration depending on the starting date, and their math is not always well defined (e.g. adding a month to the 30th of January does not make sense).""" if self.years or self.months or self.weeks or self.days: @@ -733,27 +787,13 @@ def type(self): elif self.hours or self.minutes or self.seconds or self.microseconds: return self._PHYSICAL else: - raise ConsistencyError('Error, TimeSlot not initialized?!') + raise ConsistencyError('Error, TimeSpan not initialized?!') - def is_physical(self): - """Return True if the TimeUnit type is physical, False otherwise.""" - if self.type == self._PHYSICAL: - return True - else: - return False - - def is_calendar(self): - """Return True if the TimeUnit type is calendar, False otherwise.""" - if self.type == self._CALENDAR: - return True - else: - return False - - def round(self, time, how=None): - """Round a Time or datetime according to this TimeUnit.""" + def round(self, time, how='half'): + """Round a Time or datetime according to this TimeSpan.""" if self._is_composite(): - raise ValueError('Sorry, only simple TimeUnits are supported by the round operation') + raise ValueError('Sorry, only simple TimeSpans are supported by the round operation') if isinstance(time, Time): time_dt = time.to_dt() @@ -764,24 +804,24 @@ def round(self, time, how=None): raise ValueError('The timezone of the Time or datetime is required') # Handle physical time - if self.type == self._PHYSICAL: + if self.__type == self._PHYSICAL: # Convert input time to seconds time_s = s_from_dt(time_dt) tz_offset_s = get_tz_offset(time_dt) - # Get TimeUnit duration in seconds - time_unit_s = self.as_seconds(time_dt) + # Get TimeSpan duration in seconds + time_span_s = self.as_seconds(time_dt) # Apply modular math (including timezone time translation trick if required (multiple hours)) # TODO: check for correctness, the time shift should be always done... if self.hours > 1 or self.minutes > 60: - time_floor_s = ( (time_s - tz_offset_s) - ( (time_s - tz_offset_s) % time_unit_s) ) + tz_offset_s + time_floor_s = ( (time_s - tz_offset_s) - ( (time_s - tz_offset_s) % time_span_s) ) + tz_offset_s else: - time_floor_s = time_s - (time_s % time_unit_s) + time_floor_s = time_s - (time_s % time_span_s) - time_ceil_s = time_floor_s + time_unit_s + time_ceil_s = time_floor_s + time_span_s if how == 'floor': time_rounded_s = time_floor_s @@ -801,21 +841,21 @@ def round(self, time, how=None): rounded_dt = dt_from_s(time_rounded_s, tz=time_dt.tzinfo) # Handle calendar time - elif self.type == self._CALENDAR: + elif self.__type == self._CALENDAR: if self.years: if self.years > 1: - raise NotImplementedError('Cannot round based on calendar TimeUnits with years > 1') + raise NotImplementedError('Cannot round based on calendar TimeSpans with years > 1') floored_dt=time_dt.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0) if self.months: if self.months > 1: - raise NotImplementedError('Cannot round based on calendar TimeUnits with months > 1') + raise NotImplementedError('Cannot round based on calendar TimeSpans with months > 1') floored_dt=time_dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0) if self.weeks: # Get to this day midnight - floored_dt = TimeUnit('1D').floor(time_dt) + floored_dt = TimeSpan('1D').floor(time_dt) # If not monday, subtract enought days to get there if floored_dt.weekday() != 0: @@ -823,7 +863,7 @@ def round(self, time, how=None): if self.days: if self.days > 1: - raise NotImplementedError('Cannot round based on calendar TimeUnits with days > 1') + raise NotImplementedError('Cannot round based on calendar TimeSpans with days > 1') floored_dt=time_dt.replace(hour=0, minute=0, second=0, microsecond=0) # Check DST offset consistency and fix if not respected @@ -838,7 +878,7 @@ def round(self, time, how=None): ceiled_dt = self.shift(floored_dt, 1) rounded_dt = ceiled_dt - else: + elif how == 'half': ceiled_dt = self.shift(floored_dt, 1) distance_from_time_floor_s = abs(s_from_dt(time_dt) - s_from_dt(floored_dt)) # Distance from floor distance_from_time_ceil_s = abs(s_from_dt(time_dt) - s_from_dt(ceiled_dt)) # Distance from ceil @@ -847,29 +887,31 @@ def round(self, time, how=None): rounded_dt = floored_dt else: rounded_dt = ceiled_dt + else: + raise ValueError('Unknown rounding strategy "{}"'.format(how)) # Handle other cases (Consistency error) else: - raise ConsistencyError('Error, TimeUnit type not Physical nor Calendar?!') + raise ConsistencyError('Error, TimeSpan type not Physical nor Calendar?!') # Return if isinstance(time, Time): - return Time(rounded_dt) + return Time.from_dt(rounded_dt) else: return rounded_dt def floor(self, time): - """Floor a Time or datetime according to this TimeUnit.""" + """Floor a Time or datetime according to this TimeSpan.""" return self.round(time, how='floor') def ceil(self, time): - """Ceil a Time or datetime according to this TimeUnit.""" + """Ceil a Time or datetime according to this TimeSpan.""" return self.round(time, how='ceil') def shift(self, time, times=1): - """Shift a given Time or datetime n times this TimeUnit.""" + """Shift a given Time or datetime n times this TimeSpan.""" if self._is_composite(): - raise ValueError('Sorry, only simple TimeUnits are supported by the shift operation') + raise ValueError('Sorry, only simple TimeSpans are supported by the shift operation') if isinstance(time, Time): time_dt = time.to_dt() @@ -880,21 +922,21 @@ def shift(self, time, times=1): time_s = s_from_dt(time_dt) # Handle physical time TimeSlot - if self.type == self._PHYSICAL: + if self.__type == self._PHYSICAL: - # Get TimeUnit duration in seconds - time_unit_s = self.as_seconds() + # Get TimeSpan duration in seconds + time_span_s = self.as_seconds() - time_shifted_s = time_s + ( time_unit_s * times ) + time_shifted_s = time_s + ( time_span_s * times ) time_shifted_dt = dt_from_s(time_shifted_s, tz=time_dt.tzinfo) return time_shifted_dt # Handle calendar time TimeSlot - elif self.type == self._CALENDAR: + elif self.__type == self._CALENDAR: if times != 1: - raise NotImplementedError('Cannot shift calendar TimeUnits for times greater than 1 (got times="{}")'.format(times)) + raise NotImplementedError('Cannot shift calendar TimeSpans for times greater than 1 (got times="{}")'.format(times)) # Create a TimeDelta object for everything but years and months delta = timedelta(weeks = self.weeks, @@ -935,7 +977,7 @@ def shift(self, time, times=1): # If we are here, it means that the datetime cannot be corrected. # This basically means that we are in the edge case where we ended # up in a a non-existent datetime, e.g. 2023-03-26 02:15 on Europe/Rome, - # probably by adding a calendar time unit to a previous datetime + # probably by adding a calendar time span to a previous datetime raise ValueError('Cannot shift "{}" by "{}" ({})'.format(time_dt,self,e)) from None # Check if we ended up on an ambiguous time @@ -949,23 +991,25 @@ def shift(self, time, times=1): # Return if isinstance(time, Time): - return Time(time_shifted_dt) + return Time.from_dt(time_shifted_dt) else: return time_shifted_dt - def as_seconds(self, start=None): - """The duration of the TimeUnit in seconds.""" + def as_seconds(self, starting_at=None): + """The duration of the TimeSpan in seconds.""" + + start = starting_at if start and isinstance(start, Time): start = start.to_dt() - if self.type == self._CALENDAR: + if self.__type == self._CALENDAR: if not start: - raise ValueError('You can ask to get a calendar TimeUnit as seconds only if you provide the unit starting point') + raise ValueError('You can ask to get a calendar TimeSpan as seconds only if you provide the span starting point') if self._is_composite(): - raise ValueError('Sorry, only simple TimeUnits are supported by this operation') + raise ValueError('Sorry, only simple TimeSpans are supported by this operation') # Start Epoch start_epoch = s_from_dt(start) @@ -975,21 +1019,21 @@ def as_seconds(self, start=None): end_epoch = s_from_dt(end_dt) # Get duration based on seconds - time_unit_s = end_epoch - start_epoch + time_span_s = end_epoch - start_epoch - elif self.type == 'Physical': - time_unit_s = 0 + elif self.__type == self._PHYSICAL: + time_span_s = 0 if self.hours: - time_unit_s += self.hours * 60 * 60 + time_span_s += self.hours * 60 * 60 if self.minutes: - time_unit_s += self.minutes * 60 + time_span_s += self.minutes * 60 if self.seconds: - time_unit_s += self.seconds + time_span_s += self.seconds if self.microseconds: - time_unit_s += 1/1000000.0 * self.microseconds + time_span_s += 1/1000000.0 * self.microseconds else: - raise ConsistencyError('Unknown TimeUnit type "{}"'.format(self.type)) + raise ConsistencyError('Unknown TimeSpan type "{}"'.format(self.type)) - return float(time_unit_s) + return float(time_span_s)