atlus

Convert raw address and phone number strings into the OSM format.

atlus is a Python package to convert raw address, phone number, and opening hours strings into the OSM format. It's designed to be used with US and Canadian phone numbers and addresses.

>>> import atlus
>>> atlus.abbrs("St. Francis")
"Saint Francis"
>>> atlus.get_address("789 Oak Dr, Smallville California, 98765")[0]
{"addr:housenumber": "789", "addr:street": "Oak Drive", "addr:city": "Smallville",
    "addr:state": "CA", "addr:postcode": "98765"}
>>> atlus.get_phone("(202) 900-9019")
"+1-202-900-9019"
>>> atlus.get_hours("Monday to Friday 9am-5pm, Saturday 9am-12pm")
"Mo-Fr 09:00-17:00; Sa 09:00-12:00"
>>> atlus.get_times("Mo-Fr 15:00,18:00,19:00,23:00; Sa 15:00; Su 10:30,23:00")
"Mo-Fr 15:00,18:00,19:00,23:00; Sa 15:00; Su 10:30,23:00"
 1"""Convert raw address and phone number strings into the OSM format.
 2
 3`atlus` is a Python package to convert raw address, phone number, and opening
 4hours strings into the OSM format. It's designed to be used with US and Canadian
 5phone numbers and addresses.
 6
 7```python
 8>>> import atlus
 9>>> atlus.abbrs("St. Francis")
10"Saint Francis"
11>>> atlus.get_address("789 Oak Dr, Smallville California, 98765")[0]
12{"addr:housenumber": "789", "addr:street": "Oak Drive", "addr:city": "Smallville",
13    "addr:state": "CA", "addr:postcode": "98765"}
14>>> atlus.get_phone("(202) 900-9019")
15"+1-202-900-9019"
16>>> atlus.get_hours("Monday to Friday 9am-5pm, Saturday 9am-12pm")
17"Mo-Fr 09:00-17:00; Sa 09:00-12:00"
18>>> atlus.get_times("Mo-Fr 15:00,18:00,19:00,23:00; Sa 15:00; Su 10:30,23:00")
19"Mo-Fr 15:00,18:00,19:00,23:00; Sa 15:00; Su 10:30,23:00"
20```
21
22"""
23
24# SPDX-FileCopyrightText: 2024-present Will <wahubsch@gmail.com>
25#
26# SPDX-License-Identifier: MIT
27
28from . import atlus, hours, resources
29from .atlus import (
30    abbrs,
31    get_address,
32    get_phone,
33    get_title,
34    mc_replace,
35    ord_replace,
36    remove_br_unicode,
37    us_replace,
38)
39from .hours import get_hours, get_times
40
41__all__ = [
42    "get_address",
43    "get_phone",
44    "get_hours",
45    "get_times",
46    "abbrs",
47    "get_title",
48    "mc_replace",
49    "us_replace",
50    "ord_replace",
51    "remove_br_unicode",
52    "atlus",
53    "hours",
54    "resources",
55]
def get_address(address_string: str) -> tuple[dict[str, str], list[str | None]]:
688def get_address(address_string: str) -> tuple[dict[str, str], list[str | None]]:
689    """Process address strings.
690
691    ```python
692    >>> get_address("345 MAPLE RD, COUNTRYSIDE, PA 24680-0198")[0]
693    {"addr:housenumber": "345", "addr:street": "Maple Road",
694    "addr:city": "Countryside", "addr:state": "PA", "addr:postcode": "24680-0198"}
695    >>> get_address("777 Strawberry St.")[0]
696    {"addr:housenumber": "777", "addr:street": "Strawberry Street"}
697    >>> address = get_address("222 NW Pineapple Ave Suite A Unit B")
698    >>> address[0]
699    {"addr:housenumber": "222", "addr:street": "Northwest Pineapple Avenue"}
700    >>> address[1]
701    ["addr:unit"]
702    ```
703
704    Args:
705        address_string (str): The address string to process.
706
707    Returns:
708        tuple[dict[str, str], list[str | None]]:
709        The processed address string and the removed fields.
710    """
711    if not address_string.strip().replace("\n", ""):
712        raise ValueError("Address string cannot be empty")
713
714    # Segment the address string into fields
715    cleaned, removed = _parse_address(address_string)
716
717    # Apply field-specific processors
718    cleaned = _apply_field_processors(cleaned)
719
720    # Drop fields that were parsed but came out empty
721    cleaned = {key: value for key, value in cleaned.items() if value}
722
723    # Validate and return
724    return _validate_and_clean(cleaned, removed)

Process address strings.

>>> get_address("345 MAPLE RD, COUNTRYSIDE, PA 24680-0198")[0]
{"addr:housenumber": "345", "addr:street": "Maple Road",
"addr:city": "Countryside", "addr:state": "PA", "addr:postcode": "24680-0198"}
>>> get_address("777 Strawberry St.")[0]
{"addr:housenumber": "777", "addr:street": "Strawberry Street"}
>>> address = get_address("222 NW Pineapple Ave Suite A Unit B")
>>> address[0]
{"addr:housenumber": "222", "addr:street": "Northwest Pineapple Avenue"}
>>> address[1]
["addr:unit"]
Arguments:
  • address_string (str): The address string to process.
Returns:

tuple[dict[str, str], list[str | None]]: The processed address string and the removed fields.

def get_phone(phone: str) -> str:
727def get_phone(phone: str) -> str:
728    """Format phone numbers to the US and Canadian standard format of `+1-XXX-XXX-XXXX`.
729
730    ```python
731    >>> get_phone("2029009019")
732    "+1-202-900-9019"
733    >>> get_phone("(202) 900-9019")
734    "+1-202-900-9019"
735    >>> get_phone("202-900-901")
736    ValueError: Invalid phone number: 202-900-901
737    ```
738
739    Args:
740        phone (str): The phone number to format.
741
742    Returns:
743        str: The formatted phone number.
744
745    Raises:
746        ValueError: If the phone number is invalid.
747    """
748    phone_valid = phone_comp.search(phone)
749    if phone_valid:
750        return (
751            f"+1-{phone_valid.group(1)}-{phone_valid.group(2)}-{phone_valid.group(3)}"
752        )
753    raise ValueError(f"Invalid phone number: {phone}")

Format phone numbers to the US and Canadian standard format of +1-XXX-XXX-XXXX.

>>> get_phone("2029009019")
"+1-202-900-9019"
>>> get_phone("(202) 900-9019")
"+1-202-900-9019"
>>> get_phone("202-900-901")
ValueError: Invalid phone number: 202-900-901
Arguments:
  • phone (str): The phone number to format.
Returns:

str: The formatted phone number.

Raises:
  • ValueError: If the phone number is invalid.
def get_hours(value: str) -> str:
 999def get_hours(value: str) -> str:
1000    """Process opening hours strings into the OSM `opening_hours` format.
1001
1002    ```python
1003    >>> get_hours("Mo-Fr 08:00-12:00,13:00-17:30")
1004    "Mo-Fr 08:00-12:00,13:00-17:30"
1005    >>> get_hours("Monday to Friday 9am-5pm, Saturday 9am-12pm")
1006    "Mo-Fr 09:00-17:00; Sa 09:00-12:00"
1007    >>> get_hours("Closed")
1008    "off"
1009    >>> get_hours("Mo-Fr 09:00-17:00; PH off")
1010    "Mo-Fr 09:00-17:00; PH off"
1011    >>> get_hours("Mo-Fr sunrise-sunset")
1012    "Mo-Fr sunrise-sunset"
1013    ```
1014
1015    The solar keywords `dawn`, `dusk`, `sunrise`, and `sunset` are accepted
1016    in place of a clock time (on either or both sides of a time span), and
1017    are rendered in lowercase exactly as OSM expects.
1018
1019    `PH` (public holiday) is supported as a special, non-weekday indicator:
1020    it's recognized only as the exact token `PH` (no other aliases or
1021    forms), can never be part of an actual day range (e.g. `PH-Mo` is
1022    rejected), and always sorts after every other day/rule in the output,
1023    regardless of where it appeared in the input.
1024
1025    Calendar/date-based rules -- month names or specific dates (e.g.
1026    `"Jan 1"`), named holidays (e.g. `"Easter"`, `"Thanksgiving"`), and
1027    OSM's "nth weekday of month" notation (e.g. `"Th[4]"` for the fourth
1028    Thursday) -- aren't supported. Rather than risk silently mangling them,
1029    any input containing one of these raises `ValueError` instead of
1030    returning a partial or incorrect result.
1031
1032    Note:
1033        This function has a few quirks to be aware of:
1034
1035        - Only strings with English day names (and abbreviations) are
1036          supported; day names in other languages will not be recognized.
1037        - If the same day is mentioned more than once anywhere in the
1038          string, the later mention wins and silently overrides the
1039          earlier one (e.g. "Mo 09:00-17:00, Mo 10:00-14:00" resolves to
1040          just "Mo 10:00-14:00").
1041        - If a string contains both a day range and a specific day that
1042          overlap (e.g. "Mo-Fr 09:00-17:00, We 10:00-14:00"), the explicit,
1043          more specific day definition takes precedence over the range for
1044          that day.
1045        - Days that are not mentioned anywhere in the input string are
1046          simply omitted from the output; they are not assumed to be
1047          `off`.
1048        - Bare, ambiguous times with no am/pm marker or colon (e.g. "9-5")
1049          are assumed to be typical AM-to-PM business hours, so "9-5"
1050          resolves to "09:00-17:00" rather than being rejected or resolved
1051          another way.
1052
1053    Args:
1054        value (str): The opening hours string to process.
1055
1056    Returns:
1057        str: The formatted opening hours string.
1058
1059    Raises:
1060        ValueError: If the string cannot be parsed, or if it references a
1061            calendar/date-based rule that isn't supported.
1062    """
1063    normalized = _normalize(value)
1064    if not normalized:
1065        raise ValueError("Empty opening hours string.")
1066    _reject_unsupported_calendar_refs(normalized)
1067
1068    stripped = normalized.strip()
1069    if closed_comp.fullmatch(stripped):
1070        return "off"
1071    if day_24_comp.fullmatch(stripped):
1072        return "24/7"
1073
1074    top_segments = [s for s in rule_split_comp.split(normalized) if s.strip()]
1075    top_segments = _merge_day_time_lines(top_segments)
1076    segments = [sub for top in top_segments for sub in _split_space_days(top)]
1077    segments = [sub for seg in segments for sub in _split_comma_days(seg)]
1078    rules = [_parse_segment(segment) for segment in segments]
1079    rules = _merge_duplicate_day_rules(rules)
1080
1081    # only coalesce/reorder when every rule specifies explicit days -- if any
1082    # rule applies to the whole week (e.g. "daily"), leave the input order
1083    # alone since day semantics may be intentionally layered
1084    if rules and all(rule.days for rule in rules):
1085        rules = _coalesce_rules(rules)
1086
1087    output = OpeningHours(rules=rules).to_osm()
1088    _validate_opening_hours_output(output)
1089    return output

Process opening hours strings into the OSM opening_hours format.

>>> get_hours("Mo-Fr 08:00-12:00,13:00-17:30")
"Mo-Fr 08:00-12:00,13:00-17:30"
>>> get_hours("Monday to Friday 9am-5pm, Saturday 9am-12pm")
"Mo-Fr 09:00-17:00; Sa 09:00-12:00"
>>> get_hours("Closed")
"off"
>>> get_hours("Mo-Fr 09:00-17:00; PH off")
"Mo-Fr 09:00-17:00; PH off"
>>> get_hours("Mo-Fr sunrise-sunset")
"Mo-Fr sunrise-sunset"

The solar keywords dawn, dusk, sunrise, and sunset are accepted in place of a clock time (on either or both sides of a time span), and are rendered in lowercase exactly as OSM expects.

PH (public holiday) is supported as a special, non-weekday indicator: it's recognized only as the exact token PH (no other aliases or forms), can never be part of an actual day range (e.g. PH-Mo is rejected), and always sorts after every other day/rule in the output, regardless of where it appeared in the input.

Calendar/date-based rules -- month names or specific dates (e.g. "Jan 1"), named holidays (e.g. "Easter", "Thanksgiving"), and OSM's "nth weekday of month" notation (e.g. "Th[4]" for the fourth Thursday) -- aren't supported. Rather than risk silently mangling them, any input containing one of these raises ValueError instead of returning a partial or incorrect result.

Note:

This function has a few quirks to be aware of:

  • Only strings with English day names (and abbreviations) are supported; day names in other languages will not be recognized.
  • If the same day is mentioned more than once anywhere in the string, the later mention wins and silently overrides the earlier one (e.g. "Mo 09:00-17:00, Mo 10:00-14:00" resolves to just "Mo 10:00-14:00").
  • If a string contains both a day range and a specific day that overlap (e.g. "Mo-Fr 09:00-17:00, We 10:00-14:00"), the explicit, more specific day definition takes precedence over the range for that day.
  • Days that are not mentioned anywhere in the input string are simply omitted from the output; they are not assumed to be off.
  • Bare, ambiguous times with no am/pm marker or colon (e.g. "9-5") are assumed to be typical AM-to-PM business hours, so "9-5" resolves to "09:00-17:00" rather than being rejected or resolved another way.
Arguments:
  • value (str): The opening hours string to process.
Returns:

str: The formatted opening hours string.

Raises:
  • ValueError: If the string cannot be parsed, or if it references a calendar/date-based rule that isn't supported.
def get_times(value: str) -> str:
936def get_times(value: str) -> str:
937    """Process point-in-time strings into the OSM format.
938
939    ```python
940    >>> get_times("Mo-Fr 15:00,18:00,19:00,23:00; Sa 15:00; Su 10:30,23:00")
941    "Mo-Fr 15:00,18:00,19:00,23:00; Sa 15:00; Su 10:30,23:00"
942    >>> get_times("Monday to Friday 3pm and 6pm")
943    "Mo-Fr 15:00,18:00"
944    >>> get_times("Mo-Fr sunrise,sunset")
945    "Mo-Fr sunrise,sunset"
946    >>> get_times("Monday-Friday: 4:15pm Saturday: 1:00pm Sunday: Closed")
947    "Mo-Fr 16:15; Sa 13:00"
948    ```
949
950    Point-in-time tags have no "closed" concept of their own -- a day with
951    no scheduled times simply has no entry -- so a "closed"/"off" rule
952    (e.g. `"Sunday: Closed"`) is dropped entirely rather than raising or
953    fabricating a value.
954
955    The solar keywords `dawn`, `dusk`, `sunrise`, and `sunset` are accepted
956    in place of a clock time, and are rendered in lowercase exactly as OSM
957    expects.
958
959    Calendar/date-based rules -- month names or specific dates, named
960    holidays, and OSM's "nth weekday of month" notation (e.g. `"Th[4]"`)
961    -- aren't supported. Rather than risk silently mangling them, any input
962    containing one of these raises `ValueError` instead of returning a
963    partial or incorrect result.
964
965    Args:
966        value (str): The point-in-time string to process.
967
968    Returns:
969        str: The formatted point-in-time string.
970
971    Raises:
972        ValueError: If the string cannot be parsed, or if it references a
973            calendar/date-based rule that isn't supported.
974    """
975    normalized = _normalize(value)
976    if not normalized:
977        raise ValueError("Empty collection/service times string.")
978    _reject_unsupported_calendar_refs(normalized)
979
980    top_segments = [s for s in rule_split_comp.split(normalized) if s.strip()]
981    top_segments = _merge_day_time_lines(top_segments)
982    segments = [sub for top in top_segments for sub in _split_space_days(top)]
983    segments = [sub for seg in segments for sub in _split_comma_days(seg)]
984    rules = [
985        rule
986        for rule in (_parse_point_segment(segment) for segment in segments)
987        if rule is not None
988    ]
989    rules = _merge_duplicate_point_day_rules(rules)
990
991    if rules and all(rule.days for rule in rules):
992        rules = _coalesce_point_rules(rules)
993
994    output = PointTimes(rules=rules).to_osm()
995    _validate_point_times_output(output)
996    return output

Process point-in-time strings into the OSM format.

>>> get_times("Mo-Fr 15:00,18:00,19:00,23:00; Sa 15:00; Su 10:30,23:00")
"Mo-Fr 15:00,18:00,19:00,23:00; Sa 15:00; Su 10:30,23:00"
>>> get_times("Monday to Friday 3pm and 6pm")
"Mo-Fr 15:00,18:00"
>>> get_times("Mo-Fr sunrise,sunset")
"Mo-Fr sunrise,sunset"
>>> get_times("Monday-Friday: 4:15pm Saturday: 1:00pm Sunday: Closed")
"Mo-Fr 16:15; Sa 13:00"

Point-in-time tags have no "closed" concept of their own -- a day with no scheduled times simply has no entry -- so a "closed"/"off" rule (e.g. "Sunday: Closed") is dropped entirely rather than raising or fabricating a value.

The solar keywords dawn, dusk, sunrise, and sunset are accepted in place of a clock time, and are rendered in lowercase exactly as OSM expects.

Calendar/date-based rules -- month names or specific dates, named holidays, and OSM's "nth weekday of month" notation (e.g. "Th[4]") -- aren't supported. Rather than risk silently mangling them, any input containing one of these raises ValueError instead of returning a partial or incorrect result.

Arguments:
  • value (str): The point-in-time string to process.
Returns:

str: The formatted point-in-time string.

Raises:
  • ValueError: If the string cannot be parsed, or if it references a calendar/date-based rule that isn't supported.
def abbrs(value: str) -> str:
210def abbrs(value: str) -> str:
211    """Bundle most common abbreviation expansion functions.
212
213    ```python
214    >>> abbrs("St. Francis")
215    "Saint Francis"
216    >>> abbrs("E Sewell Rd")
217    "East Sewell Road"
218    ```
219
220    Note that `St` is left alone here, since it is ambiguous between `Saint`
221    and `Street` outside a known saint name. `_process_street` resolves it
222    once the token's position in the address is known.
223
224    Args:
225        value (str): String to expand.
226
227    Returns:
228        str: Expanded string.
229    """
230    value = ord_replace(us_replace(mc_replace(get_title(value))))
231
232    # change likely 'St' to 'Saint'
233    value = saint_comp.sub("Saint", value)
234
235    # expand common street and word abbreviations
236    value = abbr_word_comp.sub(_expand_word, value)
237
238    # expand directionals
239    value = dir_fill_comp.sub(direct_expand, value)
240
241    # normalize 'US'
242    value = us_replace(value)
243
244    # uppercase shortened street descriptors
245    value = cap_comp.sub(cap_match, value)
246
247    # remove unremoved abbr periods
248    if "." in value:
249        value = period_comp.sub(r"\1", value)
250
251    # expand 'SR' if no other street types
252    value = sr_comp.sub("State Route", value)
253    return value.strip(" .")

Bundle most common abbreviation expansion functions.

>>> abbrs("St. Francis")
"Saint Francis"
>>> abbrs("E Sewell Rd")
"East Sewell Road"

Note that St is left alone here, since it is ambiguous between Saint and Street outside a known saint name. _process_street resolves it once the token's position in the address is known.

Arguments:
  • value (str): String to expand.
Returns:

str: Expanded string.

def get_title(value: str, single_word: bool = False) -> str:
57def get_title(value: str, single_word: bool = False) -> str:
58    """Fix ALL-CAPS string.
59
60    ```python
61    >>> get_title("PALM BEACH")
62    "Palm Beach"
63    >>> get_title("BOSTON")
64    "BOSTON"
65    >>> get_title("BOSTON", single_word=True)
66    "Boston"
67    >>> get_title("KING'S BEACH")
68    "King's Beach"
69    ```
70
71    Args:
72        value: String to fix.
73        single_word: Whether the string should be fixed even if it is a single word.
74
75    Returns:
76        str: Fixed string.
77    """
78    if (value.isupper() and " " in value) or (value.isupper() and single_word):
79        return mc_replace(" ".join(x.capitalize() for x in value.split()))
80    return value

Fix ALL-CAPS string.

>>> get_title("PALM BEACH")
"Palm Beach"
>>> get_title("BOSTON")
"BOSTON"
>>> get_title("BOSTON", single_word=True)
"Boston"
>>> get_title("KING'S BEACH")
"King's Beach"
Arguments:
  • value: String to fix.
  • single_word: Whether the string should be fixed even if it is a single word.
Returns:

str: Fixed string.

def mc_replace(value: str) -> str:
100def mc_replace(value: str) -> str:
101    """Fix string containing improperly formatted Mc- prefix.
102
103    ```python
104    >>> mc_replace("Fort Mchenry")
105    "Fort McHenry"
106    ```
107
108    Args:
109        value: String to fix.
110
111    Returns:
112        str: Fixed string.
113    """
114    words = []
115    for word in value.split():
116        mc_match = word.partition("Mc")
117        words.append(mc_match[0] + mc_match[1] + mc_match[2].capitalize())
118    return " ".join(words)

Fix string containing improperly formatted Mc- prefix.

>>> mc_replace("Fort Mchenry")
"Fort McHenry"
Arguments:
  • value: String to fix.
Returns:

str: Fixed string.

def us_replace(value: str) -> str:
83def us_replace(value: str) -> str:
84    """Fix string containing improperly formatted US.
85
86    ```python
87    >>> us_replace("U.S. Route 15")
88    "US Route 15"
89    ```
90
91    Args:
92        value: String to fix.
93
94    Returns:
95        str: Fixed string.
96    """
97    return value.replace("U.S.", "US").replace("U. S.", "US").replace("U S ", "US ")

Fix string containing improperly formatted US.

>>> us_replace("U.S. Route 15")
"US Route 15"
Arguments:
  • value: String to fix.
Returns:

str: Fixed string.

def ord_replace(value: str) -> str:
121def ord_replace(value: str) -> str:
122    """Fix string containing improperly capitalized ordinal.
123
124    ```python
125    >>> ord_replace("3Rd St. NW")
126    "3rd St. NW"
127    ```
128
129    Args:
130        value: String to fix.
131
132    Returns:
133        str: Fixed string.
134    """
135    return ord_comp.sub(lower_match, value)

Fix string containing improperly capitalized ordinal.

>>> ord_replace("3Rd St. NW")
"3rd St. NW"
Arguments:
  • value: String to fix.
Returns:

str: Fixed string.

def remove_br_unicode(old: str) -> str:
256def remove_br_unicode(old: str) -> str:
257    """Clean the input string before sending to parser by removing newlines and unicode.
258
259    Args:
260        old (str): String to clean.
261
262    Returns:
263        str: Cleaned string.
264    """
265    if "<br" in old:
266        old = br_comp.sub(",", old)
267    # the pattern only ever matches code points above 0x7F
268    if not old.isascii():
269        old = unicode_comp.sub("", old)
270    return old

Clean the input string before sending to parser by removing newlines and unicode.

Arguments:
  • old (str): String to clean.
Returns:

str: Cleaned string.