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]
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.