rezemika / humanized_opening_hours

A parser for the opening_hours fields from OpenStreetMap
GNU Affero General Public License v3.0
26 stars 20 forks source link
lark opening-hours openstreetmap openstreetmap-data parser python3

Humanized Opening Hours - A parser for the opening_hours fields from OSM

Humanized Opening Hours is a Python 3 module allowing a simple usage of the opening_hours fields used in OpenStreetMap.

Due to a lack of free time, the developpement of this module is paused. You can of course use it, but its features won't evolve before a (long) moment. If you want to become maintainer, don't hesitate to create an issue!

>>> import humanized_opening_hours as hoh
>>> field = "Mo-Fr 06:00-21:00; Sa,Su 08:00-12:00"
>>> oh = hoh.OHParser(field, locale="en")
>>> oh.is_open()
True
>>> oh.next_change()
datetime.datetime(2017, 12, 24, 12, 0)
>>> print('\n'.join(oh.description()))
"""
From Monday to Friday: 6:00 AM – 9:00 PM.
From Saturday to Sunday: 8:00 AM – 12:00 PM.
"""

This module is in beta. It should be production ready, but some bugs or minor modifications are still possible. Don't hesitate to create an issue!

Table of contents

Installation

This library is so small, you can include it directly into your project. Also, it is available on PyPi.

$ pip3 install osm-humanized-opening-hours

Dependencies

This module requires the following modules, which should be automatically installed when installing HOH with pip.

lark-parser
babel
astral

How to use it

The only mandatory argument to give to the constructor is the field, which must be a string. It can also take a locale argument, which can be any valid locale name. You can change it later by changing the locale attribute (which is, in fact, a property). However, to be able to use the most of the rendering methods, it must be in hoh.AVAILABLE_LOCALES (a warning will be printed otherwise).

>>> import humanized_opening_hours as hoh
>>> field = "Mo-Fr 06:00-21:00; Sa,Su 07:00-21:00"
>>> oh = hoh.OHParser(field)

If you have a GeoJSON, you can use a dedicated classmethod: from_geojson(), which returns an OHParser instance. It takes the GeoJSON, and optionally the following arguments:

Basic methods

To know if the facility is open at the present time. Returns a boolean. Can take a datetime.datetime moment to check for another time.

>>> oh.is_open()
True

To know at which time the facility status (open / closed) will change. Returns a datetime.datetime object. It can take a datetime.datetime moment to get next change from another time. If we are on December 24 before 21:00 / 09:00PM...

>>> oh.next_change()
datetime.datetime(2017, 12, 24, 21, 0)

For fields with consecutive days fully open, next_change() will try to get the true next change by recursion. You can change this behavior with the max_recursion argument, which is set to 31 default, meaning next_change() will try a maximum of 31 recursions (i.e. 31 days, or a month) to get the true next change. If this limit is reached, a NextChangeRecursionError will be raised. You can deny recursion by setting the max_recursion argument to 0.

The NextChangeRecursionError has a last_change attribute, containing the last change got just before raising of the exception. You can get it with a except NextChangeRecursionError as e: block.

>>> oh = hoh.OHParser("Mo-Fr 00:00-24:00")
>>> oh.next_change(dt=datetime.datetime(2018, 1, 8, 0, 0))
datetime.datetime(2018, 1, 11, 23, 59, 59, 999999)

To get a list of the opening periods between to dates, you can the use opening_periods_between() method. It takes two arguments, which can be datetime.date or datetime.datetime objects. If you pass datetime.date objects, it will return all opening periods between these dates (inclusive). If you pass datetime.datetime, the returned opening periods will be "choped" on these times.

The returned opening periods are tuples of two datetime.datetime objects, representing the beginning and the end of the period.

>>> oh = hoh.OHParser("Mo-Fr 06:00-21:00; Sa,Su 07:00-21:00")
>>> oh.opening_periods_between(datetime.date(2018, 1, 1), datetime.date(2018, 1, 7))
[
    (datetime.datetime(2018, 1, 1, 6, 0), datetime.datetime(2018, 1, 1, 21, 0)),
    (datetime.datetime(2018, 1, 2, 6, 0), datetime.datetime(2018, 1, 2, 21, 0)),
    (datetime.datetime(2018, 1, 3, 6, 0), datetime.datetime(2018, 1, 3, 21, 0)),
    (datetime.datetime(2018, 1, 4, 6, 0), datetime.datetime(2018, 1, 4, 21, 0)),
    (datetime.datetime(2018, 1, 5, 6, 0), datetime.datetime(2018, 1, 5, 21, 0)),
    (datetime.datetime(2018, 1, 6, 7, 0), datetime.datetime(2018, 1, 6, 21, 0)),
    (datetime.datetime(2018, 1, 7, 7, 0), datetime.datetime(2018, 1, 7, 21, 0))
]

You can also set the merge parameter to True, to merge continuous opening periods.


You can get a sanitized version of the field given to the constructor with the sanitize() function or the field attribute.

>>> field = "mo-su 09:30-20h;jan off"
>>> print(hoh.sanitize(field))
"Mo-Su 09:30-20:00; Jan off"

If sanitization is the only thing you need, use HOH for this is probably overkill. You might be interested in the OH Sanitizer module, or you can copy directly the code of the sanitize function in your project.


If you try to parse a field which is invalid or contains a pattern which is not supported, an humanized_opening_hours.exceptions.ParseError (inheriting from humanized_opening_hours.exceptions.HOHError) will be raised.

If a field contains only a comment (like "on appointment"), a CommentOnlyField exception (inheriting from ParseError) will be raised. It contains a comment attribute, allowing you to display it instead of the opening hours.

The OHParser contains an is_24_7 attribute, which is true if the field is simply 24/7 or 00:00-24:00, and false either. The next_change() method won't try recursion if this attribute is true and will directly raise a NextChangeRecursionError (except if you set max_recursion to zero, in this case it will just return the last time of the current day).

You can check equality between two OHParser instances. It will be true if both have the same field and the same location.

>>> import humanized_opening_hours as hoh
>>> 
>>> oh1 = hoh.OHParser("Mo 10:00-20:00")
>>> oh2 = hoh.OHParser("Mo 10:00-20:00")
>>> oh3 = hoh.OHParser("Mo 09:00-21:00")
>>> oh1 == oh2
True
>>> oh1 == oh3
False

The OHParser object contains two other attributes: PH_dates and SH_dates, which are empty lists default. To indicate a date is a public or a school holiday, you can pass its datetime.date into these lists. You can also use the python-holidays module to get dynamic dictionnary (which updates the year) to replace these lists. In fact, any iterable object with a __contains__ method (receiving datetime.date objects) will work. If you have GPS coordinates and want to have a country name, you can use the countries module.

Solar hours

If the field contains solar hours, here is how to deal with them.

First of all, you can easily know if you need to set them by checking the OHParser.needs_solar_hours_setting variable. If one of its values is True, it appears in the field and you should give to HOH a mean to retrive its time.

You have to ways to do this. The first is to give to the OHParser the location of the facility, to allow it to calculate solar hours. The second is to use the SolarHours object (which inherits from dict), via the OHParser.solar_hours attribute.

# First method. You can use either an 'astral.Location' object or a tuple.
location = astral.Location(["Greenwich", "England", 51.168, 0.0, "Europe/London", 24])
location = (51.168, 0.0, "Europe/London", 24)
oh = hoh.OHParser(field, location=location)

# Second method.
solar_hours = {
    "sunrise": datetime.time(8, 0), "sunset": datetime.time(20, 0),
    "dawn": datetime.time(7, 30), "dusk": datetime.time(20, 30)
}
oh.solar_hours[datetime.date.today()] = solar_hours

Attention, except if the facility is on the equator, this setting will be valid only for a short period (except if you provide coordinates, because they will be automatically updated).

If you try to do something with a field containing solar hours without providing a location, a humanized_opening_hours.exceptions.SolarHoursError exception will be raised.

In some very rare cases, it might be impossible to get solar hours. For example, in Antactica, the sun may never reach the dawn / dusk location in the sky, so the astral module can't return the down time. So, if you try to get, for example, the next change with a field containing solar hours and located in such location, a humanized_opening_hours.exceptions.SolarHoursError exception will also be raised.


Sometimes, especially if you work with numerous fields, you may want to apply the same methods to the same field but for different locations. To do so, you can use a dedicated method called this_location(), which is intended to be used as a context manager. It allows you to temporarily set a specific location to the OHParser instance.

oh = hoh.OHParser(
    "Mo-Fr sunrise-sunset",
    location=(51.168, 0.0, "Europe/London", 24)
)

str(oh.solar_hours.location) == 'Location/Region, tz=Europe/London, lat=51.17, lon=0.00'

with oh.temporary_location("Paris"):
    str(oh.solar_hours.location) == 'Paris/France, tz=Europe/Paris, lat=48.83, lon=2.33'

str(oh.solar_hours.location) == 'Location/Region, tz=Europe/London, lat=51.17, lon=0.00'

Have nice schedules

You can pass any valid locale name to OHParser, it will work for the majority of methods, cause they only need Babel's translations. However, the description() and plaintext_week_description() methods need more translations, so it works only with a few locales, whose list is available with hoh.AVAILABLE_LOCALES. Use another one will make methods return inconsistent sentences.

Currently, the following locales are supported:


The get_localized_names() method returns a dict of lists with the names of months and weekdays in the current locale.

Example:

>>> oh.get_localized_names()
{
    'months': [
        'January', 'February', 'March',
        'April', 'May', 'June', 'July',
        'August', 'September', 'October',
        'November', 'December'
    ],
    'days': [
        'Monday', 'Tuesday', 'Wednesday',
        'Thursday', 'Friday', 'Saturday',
        'Sunday'
    ]
}

time_before_next_change() returns a humanized delay before the next change in opening status. Like next_change(), it can take a datetime.datetime moment to get next change from another time.

>>> oh.time_before_next_change()
"in 3 hours"
>>> oh.time_before_next_change(word=False)
"3 hours"

description() returns a list of strings (sentences) describing the whole field.

# Field: "Mo-Fr 10:00-19:00; Sa 10:00-12:00; Dec 25 off"
>>> print(oh.description())
['From Monday to Friday: 10:00 AM – 7:00 PM.', 'On Saturday: 10:00 AM – 12:00 PM.', 'December 25: closed.']
>>> print('\n'.join(oh.description()))
"""
From Monday to Friday: 10:00 AM – 7:00 PM.
On Saturday: 10:00 AM – 12:00 PM.
December 25: closed.
"""

plaintext_week_description() returns a plaintext description of the opening periods of a week. This method takes a year and a weeknumber (both int). You can also specify the first day of the week with the first_weekday parameter (as int). Its default value is 0, meaning "Monday".

It can also take no parameter, so the described week will be the current one.

>>> print(oh.plaintext_week_description(year=2018, weeknumber=1, first_weekday=0))
"""
Monday: 8:00 AM – 7:00 PM
Tuesday: 8:00 AM – 7:00 PM
Wednesday: 8:00 AM – 7:00 PM
Thursday: 8:00 AM – 7:00 PM
Friday: 8:00 AM – 7:00 PM
Saturday: 8:00 AM – 12:00 PM
Sunday: closed
"""

This method uses the days_of_week() function to get the datetimes of the days of the requested week. It is accessible directly through the HOH namespace, and takes the same parameters.


get_day() returns a Day object, which contains opening periods and useful methods for a day. It can take a datetime.date argument to get the day you want.

The returned object contains the following attributes.

Attention, the datetime.datetime objects in the computed timespans may be in another day, if it contains a period which spans over midnight (like Mo-Fr 20:00-02:00).

Supported field formats

Here are the field formats officialy supported and tested (examples).

24/7
Mo 10:00-20:00
Mo-Fr 10:00-20:00
Sa,Su 10:00-20:00
Su,PH off  # or "closed"
10:00-20:00
20:00-02:00
sunrise-sunset  # or "dawn" / "dusk"
(sunrise+01:00)-20:00
Jan 10:00-20:00
Jan-Feb 10:00-20:00
Jan,Dec 10:00-20:00
Jan Mo 10:00-20:00
Jan,Feb Mo 10:00-20:00
Jan-Feb Mo 10:00-20:00
Jan Mo-Fr 10:00-20:00
Jan,Feb Mo-Fr 10:00-20:00
Jan-Feb Mo-Fr 10:00-20:00
SH Mo 10:00-20:00
SH Mo-Fr 10:00-20:00
easter 10:00-20:00
SH,PH Mo-Fr 10:00-20:00
SH,PH Mo-Fr,Su 10:00-20:00
Jan-Feb,Aug Mo-Fr,Su 10:00-20:00
week 1 Mo 09:00-12:00
week 1-10 Su 09:00-12:00
week 1-10/2 Sa-Su 09:00-12:00
2018 Mo-Fr 10:00-20:00
2018-2022 Mo-Fr 10:00-20:00
2018-2022/2 Mo-Fr 10:00-20:00

The following formats are NOT supported yet and their parsing will raise a ParseError.

Su[1] 10:00-20:00
easter +1 day 10:00-20:00
easter +2 days 10:00-20:00
Mo-Fr 10:00+
Mo-Fr 10:00,12:00,20:00  # Does not support points in time.

For fields like 24/7; Su 10:00-13:00 off, Sundays are considered as entirely closed. This should be fixed in a later version.

Alternatives

If you want to parse opening_hours fields but HOH doesn't fit your needs, here are a few other libraries which might interest you.

Performances

HOH uses the module Lark (with the Earley parser) to parse the fields.

It is very optimized (about 20 times faster) for the simplest fields (like Mo-Fr 10:00-20:00), so their parsing will be very fast:

For more complex fields (like Jan-Feb Mo-Fr 08:00-19:00), the parsing is slower:

Licence

This module is published under the AGPLv3 license, the terms of which can be found in the LICENCE file.