timeplace 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,165 @@
1
+ Metadata-Version: 2.3
2
+ Name: timeplace
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Author: Ryan Vandersmith
6
+ Author-email: Ryan Vandersmith <ryanvandersmith@gmail.com>
7
+ Requires-Dist: geopy>=2.5.0
8
+ Requires-Dist: timezonefinder>=9.2.0
9
+ Requires-Dist: tzdata>=2026.5
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+
13
+ # timeplace
14
+
15
+ Parse and format **timeplace** lines: a single line of text naming a time, a place, and a comment.
16
+
17
+ ```
18
+ 2025-05-18 5:30pm Arnett, OK # Several tornadoes
19
+ 2022-12-02 05z 40.01499,-105.27055 # Downslope windstorm, 50-80mph
20
+ ```
21
+
22
+ See [SPEC.md](../SPEC.md) for the format. Requires Python 3.12+.
23
+
24
+ ## Install
25
+
26
+ ```sh
27
+ uv add timeplace # or: pip install timeplace
28
+ ```
29
+
30
+ ## Parsing
31
+
32
+ Every part of a line is optional, but the order is always time, place, comment.
33
+
34
+ ```python
35
+ import datetime as dt
36
+ from timeplace import parse
37
+
38
+ tp = parse("2021-12-30 2pm Boulder, CO # Marshall Fire downslope wind, 100mph")
39
+ tp.time # datetime(2021, 12, 30, 14, 0, tzinfo=ZoneInfo('America/Denver'))
40
+ tp.place # Place(lat=40.01..., lon=-105.27..., name='Boulder, CO')
41
+ tp.comment # '# Marshall Fire downslope wind, 100mph'
42
+ ```
43
+
44
+ Place names are geocoded with Nominatim (OpenStreetMap). Local clock times (`am`/`pm`) use the
45
+ time zone of the place; `z` times are UTC.
46
+
47
+ ```python
48
+ parse("2022-12-02 05z Boulder, CO") # UTC time
49
+ parse("2018-05-28 5pm 39.62261,-102.97337") # coordinates: no geocoding needed
50
+ parse("2026-1-2") # date only -> tp.time is a datetime.date
51
+ parse("Show Low, AZ") # place only
52
+ parse("#rotorcloud") # comment only
53
+ parse("2026-01-12 10am Nowhere") # geocoding failed: tp.place is None, tp.time is a date
54
+ ```
55
+
56
+ `TimePlace.parse(text)` is equivalent to `parse(text)`.
57
+
58
+ ### Fallback time zone
59
+
60
+ When the place is missing or unresolved, a local clock time cannot be resolved. Supply `tz`:
61
+
62
+ ```python
63
+ from zoneinfo import ZoneInfo
64
+
65
+ tp = parse("2026-01-12 10am", tz=ZoneInfo("America/Denver"))
66
+ tp.time # datetime(2026, 1, 12, 10, 0, tzinfo=ZoneInfo('America/Denver'))
67
+ ```
68
+
69
+ ### Invalid values
70
+
71
+ ```python
72
+ parse("2026-13-01") # ValueError: month must be in 1..12
73
+ parse("2026-01-01 13pm 1,1") # ValueError: invalid 12-hour clock hour
74
+ parse("100,0") # ValueError: latitude out of range
75
+ ```
76
+
77
+ ### Strict mode
78
+
79
+ `strict=True` accepts only `[date clock(z)] lat,lon [comment]`, never geocodes, and raises
80
+ `ValueError` for anything else. Use it for machine-generated data.
81
+
82
+ ```python
83
+ parse("2022-12-02 05z 40.01499,-105.27055 # hi", strict=True) # OK
84
+ parse("Boulder, CO", strict=True) # ValueError
85
+ parse("2022-12-02 5pm 1,1", strict=True) # ValueError (not UTC)
86
+ ```
87
+
88
+ ## Tags
89
+
90
+ Tags are `#word` tokens in the comment, lowercased, as a `frozenset`. `# text` (hash then space)
91
+ is the comment delimiter, and URL fragments are ignored.
92
+
93
+ ```python
94
+ tp = parse("2026-10-21 1,1 #Photogenic #supercell https://x.com/a#frag #a-b")
95
+ tp.tags # frozenset({'photogenic', 'supercell', 'a-b'})
96
+ ```
97
+
98
+ ## Formatting
99
+
100
+ `str(tp)` / `tp.format()` gives the display form: local time, place name.
101
+ `format(canonical=True)` gives the canonical form: UTC time, `lat,lon` rounded to 5 decimals.
102
+
103
+ ```python
104
+ tp = parse("2022-12-02 5am Boulder, CO # Downslope")
105
+ str(tp) # '2022-12-02 5am Boulder, CO # Downslope'
106
+ tp.format(canonical=True) # '2022-12-02 12z 40.01499,-105.27055 # Downslope'
107
+ ```
108
+
109
+ ## Building values directly
110
+
111
+ `TimePlace` is a frozen dataclass; `time` must be timezone-aware (or a `date`).
112
+
113
+ ```python
114
+ import datetime as dt
115
+ from timeplace import Place, TimePlace
116
+
117
+ tp = TimePlace(
118
+ time=dt.datetime(2026, 4, 23, 20, tzinfo=dt.UTC),
119
+ place=Place(36.3956, -97.8784, "Enid, OK"),
120
+ comment="#tornado #ef4",
121
+ )
122
+ tp.format(canonical=True) # '2026-04-23 20z 36.3956,-97.8784 #tornado #ef4'
123
+ tp.replace(comment="# updated") # new TimePlace with a different comment
124
+ Place(36.3956, -97.8784).timezone() # ZoneInfo('America/Chicago')
125
+ Place(91, 0) # ValueError
126
+ ```
127
+
128
+ ## Geocoding
129
+
130
+ The default geocoder is `NominatimGeocoder`: it sends a `timeplace` User-Agent, caches results
131
+ in memory, and limits requests to 1 per second, as the
132
+ [Nominatim usage policy](https://operations.osmfoundation.org/policies/nominatim/) requires.
133
+ Failures return `None`.
134
+
135
+ Use your own user agent or settings:
136
+
137
+ ```python
138
+ from timeplace import NominatimGeocoder, parse
139
+
140
+ geocoder = NominatimGeocoder("my-app/1.0 (me@example.com)", min_interval=1.0, timeout=10)
141
+ tp = parse("2025-05-18 5:30pm Arnett, OK", geocoder=geocoder)
142
+ ```
143
+
144
+ A geocoder is any `Callable[[str], Place | None]`, so offline lookups and test doubles are easy:
145
+
146
+ ```python
147
+ from timeplace import Place, parse
148
+
149
+ known = {"Boulder, CO": Place(40.01499, -105.27055, "Boulder, CO")}
150
+ tp = parse("2021-12-30 2pm Boulder, CO", geocoder=known.get)
151
+ ```
152
+
153
+ ## Development
154
+
155
+ ```sh
156
+ uv sync
157
+ uv run pytest # offline: Nominatim is mocked
158
+ uv run pytest --live # also run tests against the real Nominatim service
159
+ uv run ruff check
160
+ uv run mypy src tests
161
+ ```
162
+
163
+ Live tests are marked `live` and skipped by default. In CI they run only via manual
164
+ "Run workflow" with the `live` input enabled.
165
+
@@ -0,0 +1,153 @@
1
+ # timeplace
2
+
3
+ Parse and format **timeplace** lines: a single line of text naming a time, a place, and a comment.
4
+
5
+ ```
6
+ 2025-05-18 5:30pm Arnett, OK # Several tornadoes
7
+ 2022-12-02 05z 40.01499,-105.27055 # Downslope windstorm, 50-80mph
8
+ ```
9
+
10
+ See [SPEC.md](../SPEC.md) for the format. Requires Python 3.12+.
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ uv add timeplace # or: pip install timeplace
16
+ ```
17
+
18
+ ## Parsing
19
+
20
+ Every part of a line is optional, but the order is always time, place, comment.
21
+
22
+ ```python
23
+ import datetime as dt
24
+ from timeplace import parse
25
+
26
+ tp = parse("2021-12-30 2pm Boulder, CO # Marshall Fire downslope wind, 100mph")
27
+ tp.time # datetime(2021, 12, 30, 14, 0, tzinfo=ZoneInfo('America/Denver'))
28
+ tp.place # Place(lat=40.01..., lon=-105.27..., name='Boulder, CO')
29
+ tp.comment # '# Marshall Fire downslope wind, 100mph'
30
+ ```
31
+
32
+ Place names are geocoded with Nominatim (OpenStreetMap). Local clock times (`am`/`pm`) use the
33
+ time zone of the place; `z` times are UTC.
34
+
35
+ ```python
36
+ parse("2022-12-02 05z Boulder, CO") # UTC time
37
+ parse("2018-05-28 5pm 39.62261,-102.97337") # coordinates: no geocoding needed
38
+ parse("2026-1-2") # date only -> tp.time is a datetime.date
39
+ parse("Show Low, AZ") # place only
40
+ parse("#rotorcloud") # comment only
41
+ parse("2026-01-12 10am Nowhere") # geocoding failed: tp.place is None, tp.time is a date
42
+ ```
43
+
44
+ `TimePlace.parse(text)` is equivalent to `parse(text)`.
45
+
46
+ ### Fallback time zone
47
+
48
+ When the place is missing or unresolved, a local clock time cannot be resolved. Supply `tz`:
49
+
50
+ ```python
51
+ from zoneinfo import ZoneInfo
52
+
53
+ tp = parse("2026-01-12 10am", tz=ZoneInfo("America/Denver"))
54
+ tp.time # datetime(2026, 1, 12, 10, 0, tzinfo=ZoneInfo('America/Denver'))
55
+ ```
56
+
57
+ ### Invalid values
58
+
59
+ ```python
60
+ parse("2026-13-01") # ValueError: month must be in 1..12
61
+ parse("2026-01-01 13pm 1,1") # ValueError: invalid 12-hour clock hour
62
+ parse("100,0") # ValueError: latitude out of range
63
+ ```
64
+
65
+ ### Strict mode
66
+
67
+ `strict=True` accepts only `[date clock(z)] lat,lon [comment]`, never geocodes, and raises
68
+ `ValueError` for anything else. Use it for machine-generated data.
69
+
70
+ ```python
71
+ parse("2022-12-02 05z 40.01499,-105.27055 # hi", strict=True) # OK
72
+ parse("Boulder, CO", strict=True) # ValueError
73
+ parse("2022-12-02 5pm 1,1", strict=True) # ValueError (not UTC)
74
+ ```
75
+
76
+ ## Tags
77
+
78
+ Tags are `#word` tokens in the comment, lowercased, as a `frozenset`. `# text` (hash then space)
79
+ is the comment delimiter, and URL fragments are ignored.
80
+
81
+ ```python
82
+ tp = parse("2026-10-21 1,1 #Photogenic #supercell https://x.com/a#frag #a-b")
83
+ tp.tags # frozenset({'photogenic', 'supercell', 'a-b'})
84
+ ```
85
+
86
+ ## Formatting
87
+
88
+ `str(tp)` / `tp.format()` gives the display form: local time, place name.
89
+ `format(canonical=True)` gives the canonical form: UTC time, `lat,lon` rounded to 5 decimals.
90
+
91
+ ```python
92
+ tp = parse("2022-12-02 5am Boulder, CO # Downslope")
93
+ str(tp) # '2022-12-02 5am Boulder, CO # Downslope'
94
+ tp.format(canonical=True) # '2022-12-02 12z 40.01499,-105.27055 # Downslope'
95
+ ```
96
+
97
+ ## Building values directly
98
+
99
+ `TimePlace` is a frozen dataclass; `time` must be timezone-aware (or a `date`).
100
+
101
+ ```python
102
+ import datetime as dt
103
+ from timeplace import Place, TimePlace
104
+
105
+ tp = TimePlace(
106
+ time=dt.datetime(2026, 4, 23, 20, tzinfo=dt.UTC),
107
+ place=Place(36.3956, -97.8784, "Enid, OK"),
108
+ comment="#tornado #ef4",
109
+ )
110
+ tp.format(canonical=True) # '2026-04-23 20z 36.3956,-97.8784 #tornado #ef4'
111
+ tp.replace(comment="# updated") # new TimePlace with a different comment
112
+ Place(36.3956, -97.8784).timezone() # ZoneInfo('America/Chicago')
113
+ Place(91, 0) # ValueError
114
+ ```
115
+
116
+ ## Geocoding
117
+
118
+ The default geocoder is `NominatimGeocoder`: it sends a `timeplace` User-Agent, caches results
119
+ in memory, and limits requests to 1 per second, as the
120
+ [Nominatim usage policy](https://operations.osmfoundation.org/policies/nominatim/) requires.
121
+ Failures return `None`.
122
+
123
+ Use your own user agent or settings:
124
+
125
+ ```python
126
+ from timeplace import NominatimGeocoder, parse
127
+
128
+ geocoder = NominatimGeocoder("my-app/1.0 (me@example.com)", min_interval=1.0, timeout=10)
129
+ tp = parse("2025-05-18 5:30pm Arnett, OK", geocoder=geocoder)
130
+ ```
131
+
132
+ A geocoder is any `Callable[[str], Place | None]`, so offline lookups and test doubles are easy:
133
+
134
+ ```python
135
+ from timeplace import Place, parse
136
+
137
+ known = {"Boulder, CO": Place(40.01499, -105.27055, "Boulder, CO")}
138
+ tp = parse("2021-12-30 2pm Boulder, CO", geocoder=known.get)
139
+ ```
140
+
141
+ ## Development
142
+
143
+ ```sh
144
+ uv sync
145
+ uv run pytest # offline: Nominatim is mocked
146
+ uv run pytest --live # also run tests against the real Nominatim service
147
+ uv run ruff check
148
+ uv run mypy src tests
149
+ ```
150
+
151
+ Live tests are marked `live` and skipped by default. In CI they run only via manual
152
+ "Run workflow" with the `live` input enabled.
153
+
@@ -0,0 +1,50 @@
1
+ [project]
2
+ name = "timeplace"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "geopy>=2.5.0",
9
+ "timezonefinder>=9.2.0",
10
+ "tzdata>=2026.5",
11
+ ]
12
+
13
+ [[project.authors]]
14
+ name = "Ryan Vandersmith"
15
+ email = "ryanvandersmith@gmail.com"
16
+
17
+ [build-system]
18
+ requires = ["uv_build>=0.12.13,<0.13.0"]
19
+ build-backend = "uv_build"
20
+
21
+ [dependency-groups]
22
+ dev = [
23
+ "mypy>=2.4.0",
24
+ "pdoc>=15.0.0",
25
+ "pytest>=9.1.1",
26
+ "ruff>=0.17.2",
27
+ ]
28
+
29
+ [tool.mypy]
30
+ strict = true
31
+
32
+ [[tool.mypy.overrides]]
33
+ module = ["geopy.*"]
34
+ ignore_missing_imports = true
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
38
+
39
+ [tool.ruff]
40
+ line-length = 100
41
+
42
+ [tool.ruff.lint]
43
+ select = [
44
+ "E",
45
+ "F",
46
+ "I",
47
+ "UP",
48
+ "B",
49
+ "SIM",
50
+ ]
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "timeplace"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Ryan Vandersmith", email = "ryanvandersmith@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "geopy>=2.5.0",
12
+ "timezonefinder>=9.2.0",
13
+ "tzdata>=2026.5",
14
+ ]
15
+
16
+ [build-system]
17
+ requires = ["uv_build>=0.12.13,<0.13.0"]
18
+ build-backend = "uv_build"
19
+
20
+ [dependency-groups]
21
+ dev = [
22
+ "mypy>=2.4.0",
23
+ "pdoc>=15.0.0",
24
+ "pytest>=9.1.1",
25
+ "ruff>=0.17.2",
26
+ ]
27
+
28
+ [tool.mypy]
29
+ strict = true
30
+
31
+ [[tool.mypy.overrides]]
32
+ module = ["geopy.*"]
33
+ ignore_missing_imports = true
34
+
35
+ [tool.ruff.lint]
36
+ select = ["E", "F", "I", "UP", "B", "SIM"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
40
+
41
+ [tool.ruff]
42
+ line-length = 100
@@ -0,0 +1,13 @@
1
+ """Parse and format timeplace lines: a moment, a place, and a comment."""
2
+
3
+ from .core import TimePlace, format_timeplace, parse
4
+ from .geo import Geocoder, NominatimGeocoder, Place
5
+
6
+ __all__ = [
7
+ "Geocoder",
8
+ "NominatimGeocoder",
9
+ "Place",
10
+ "TimePlace",
11
+ "format_timeplace",
12
+ "parse",
13
+ ]
@@ -0,0 +1,161 @@
1
+ """Parsing and formatting of timeplace lines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ import re
7
+ from dataclasses import dataclass, replace
8
+
9
+ from .geo import Geocoder, NominatimGeocoder, Place
10
+
11
+ _LINE_RE = re.compile(
12
+ r"""
13
+ \s*
14
+ (?:
15
+ (?P<year>\d{4})-(?P<month>\d{1,2})-(?P<day>\d{1,2})
16
+ (?:[ ](?P<hour>\d{1,2})(?::(?P<minute>\d{2}))?(?P<zone>z|am|pm|[ ]am|[ ]pm)(?![a-z]))?
17
+ )?
18
+ \s*
19
+ (?P<place>[^#]*)
20
+ (?P<comment>\#.*)?
21
+ """,
22
+ re.IGNORECASE | re.VERBOSE | re.DOTALL,
23
+ )
24
+ _NUM = r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)"
25
+ _COORDS_RE = re.compile(rf"({_NUM})\s*,\s*({_NUM})")
26
+ _TAG_RE = re.compile(r"(?:^|(?<=\s))#(\w[\w\-]*)")
27
+
28
+ _default_geocoder: Geocoder | None = None
29
+
30
+
31
+ def _get_default_geocoder() -> Geocoder:
32
+ global _default_geocoder
33
+ if _default_geocoder is None:
34
+ _default_geocoder = NominatimGeocoder()
35
+ return _default_geocoder
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class TimePlace:
40
+ """A moment, a place, and a comment; every part is optional.
41
+
42
+ `time` is a timezone-aware `datetime`, a plain `date` (no clock time, or an
43
+ unresolvable local time), or `None`.
44
+ """
45
+
46
+ time: dt.datetime | dt.date | None = None
47
+ place: Place | None = None
48
+ comment: str | None = None
49
+
50
+ def __post_init__(self) -> None:
51
+ if isinstance(self.time, dt.datetime) and self.time.tzinfo is None:
52
+ raise ValueError("time must be timezone-aware")
53
+
54
+ @property
55
+ def tags(self) -> frozenset[str]:
56
+ return frozenset(t.lower() for t in _TAG_RE.findall(self.comment or ""))
57
+
58
+ def replace(self, **changes: object) -> TimePlace:
59
+ return replace(self, **changes) # type: ignore[arg-type]
60
+
61
+ @staticmethod
62
+ def parse(
63
+ text: str,
64
+ *,
65
+ geocoder: Geocoder | None = None,
66
+ tz: dt.tzinfo | None = None,
67
+ strict: bool = False,
68
+ ) -> TimePlace:
69
+ return parse(text, geocoder=geocoder, tz=tz, strict=strict)
70
+
71
+ def format(self, *, canonical: bool = False) -> str:
72
+ return format_timeplace(self, canonical=canonical)
73
+
74
+ def __str__(self) -> str:
75
+ return self.format()
76
+
77
+
78
+ def parse(
79
+ text: str,
80
+ *,
81
+ geocoder: Geocoder | None = None,
82
+ tz: dt.tzinfo | None = None,
83
+ strict: bool = False,
84
+ ) -> TimePlace:
85
+ """Parse a timeplace line.
86
+
87
+ Names are geocoded with `geocoder` (Nominatim by default); failures leave the
88
+ place unresolved. Local clock times use the place's time zone, else `tz`.
89
+ With `strict=True`, no geocoding happens and `ValueError` is raised for
90
+ anything outside the strict subset.
91
+ """
92
+ m = _LINE_RE.fullmatch(text.strip("\r\n"))
93
+ assert m is not None # every group is optional and the tail matches anything
94
+ place_text = m["place"].strip()
95
+ comment = m["comment"]
96
+
97
+ place: Place | None = None
98
+ if place_text:
99
+ coords = _COORDS_RE.fullmatch(place_text)
100
+ if coords:
101
+ place = Place(float(coords[1]), float(coords[2]))
102
+ elif strict:
103
+ raise ValueError(f"strict form requires lat,lon place: {place_text!r}")
104
+ else:
105
+ place = (geocoder or _get_default_geocoder())(place_text)
106
+
107
+ zone = m["zone"].strip().lower() if m["zone"] else None
108
+ if strict:
109
+ if place is None:
110
+ raise ValueError("strict form requires a place")
111
+ if m["year"] is not None and zone is None:
112
+ raise ValueError("strict form requires date and clock together")
113
+ if zone not in (None, "z"):
114
+ raise ValueError("strict form requires UTC (z) clock time")
115
+
116
+ time: dt.datetime | dt.date | None = None
117
+ if m["year"] is not None:
118
+ date = dt.date(int(m["year"]), int(m["month"]), int(m["day"]))
119
+ time = date
120
+ if m["hour"] is not None:
121
+ assert zone is not None
122
+ hour, minute = int(m["hour"]), int(m["minute"] or 0)
123
+ if minute > 59:
124
+ raise ValueError(f"invalid minute: {minute}")
125
+ if zone == "z":
126
+ if hour > 23:
127
+ raise ValueError(f"invalid hour: {hour}")
128
+ tzinfo: dt.tzinfo | None = dt.UTC
129
+ else:
130
+ if not 1 <= hour <= 12:
131
+ raise ValueError(f"invalid 12-hour clock hour: {hour}")
132
+ hour = hour % 12 + (12 if zone == "pm" else 0)
133
+ tzinfo = (place.timezone() if place else None) or tz
134
+ if tzinfo is not None:
135
+ time = dt.datetime.combine(date, dt.time(hour, minute), tzinfo)
136
+
137
+ return TimePlace(time, place, comment)
138
+
139
+
140
+ def format_timeplace(tp: TimePlace, *, canonical: bool = False) -> str:
141
+ parts: list[str] = []
142
+ time = tp.time
143
+ if isinstance(time, dt.datetime):
144
+ if canonical:
145
+ time = time.astimezone(dt.UTC)
146
+ minutes = f":{time.minute:02d}" if time.minute else ""
147
+ if time.tzinfo is dt.UTC:
148
+ clock = f"{time.hour:02d}{minutes}z"
149
+ else:
150
+ clock = f"{(time.hour - 1) % 12 + 1}{minutes}{'pm' if time.hour >= 12 else 'am'}"
151
+ parts.append(f"{time:%Y-%m-%d} {clock}")
152
+ elif time is not None:
153
+ parts.append(f"{time:%Y-%m-%d}")
154
+ if tp.place is not None:
155
+ if canonical or tp.place.name is None:
156
+ parts.append(tp.place.coords)
157
+ else:
158
+ parts.append(tp.place.name.replace("#", "_"))
159
+ if tp.comment is not None:
160
+ parts.append(tp.comment)
161
+ return " ".join(parts)
@@ -0,0 +1,87 @@
1
+ """Geocoding and time zone lookup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from functools import lru_cache
10
+ from zoneinfo import ZoneInfo
11
+
12
+ from geopy.exc import GeopyError
13
+ from geopy.geocoders import Nominatim
14
+ from timezonefinder import TimezoneFinder
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class Place:
19
+ lat: float
20
+ lon: float
21
+ name: str | None = None
22
+
23
+ def __post_init__(self) -> None:
24
+ if not -90 <= self.lat <= 90:
25
+ raise ValueError(f"latitude out of range: {self.lat}")
26
+ if not -180 <= self.lon <= 180:
27
+ raise ValueError(f"longitude out of range: {self.lon}")
28
+
29
+ @property
30
+ def coords(self) -> str:
31
+ return f"{_fmt_coord(self.lat)},{_fmt_coord(self.lon)}"
32
+
33
+ def timezone(self) -> ZoneInfo | None:
34
+ return find_timezone(self.lat, self.lon)
35
+
36
+
37
+ type Geocoder = Callable[[str], Place | None]
38
+
39
+
40
+ def _fmt_coord(value: float) -> str:
41
+ text = f"{value:.5f}".rstrip("0").rstrip(".")
42
+ return "0" if text in ("-0", "") else text
43
+
44
+
45
+ @lru_cache(maxsize=4096)
46
+ def find_timezone(lat: float, lon: float) -> ZoneInfo | None:
47
+ key = _tz_finder().timezone_at(lat=lat, lng=lon)
48
+ return ZoneInfo(key) if key else None
49
+
50
+
51
+ @lru_cache(maxsize=1)
52
+ def _tz_finder() -> TimezoneFinder:
53
+ return TimezoneFinder()
54
+
55
+
56
+ class NominatimGeocoder:
57
+ """Nominatim geocoder that identifies itself, caches, and limits to 1 request/s."""
58
+
59
+ def __init__(
60
+ self,
61
+ user_agent: str = "timeplace",
62
+ *,
63
+ min_interval: float = 1.0,
64
+ timeout: float = 10.0,
65
+ ) -> None:
66
+ self._client = Nominatim(user_agent=user_agent, timeout=timeout)
67
+ self._min_interval = min_interval
68
+ self._cache: dict[str, Place | None] = {}
69
+ self._lock = threading.Lock()
70
+ self._last_request = float("-inf")
71
+
72
+ def __call__(self, query: str) -> Place | None:
73
+ with self._lock:
74
+ if query in self._cache:
75
+ return self._cache[query]
76
+ wait = self._last_request + self._min_interval - time.monotonic()
77
+ if wait > 0:
78
+ time.sleep(wait)
79
+ try:
80
+ result = self._client.geocode(query)
81
+ except GeopyError: # service errors leave the place unresolved
82
+ return None
83
+ finally:
84
+ self._last_request = time.monotonic()
85
+ place = Place(result.latitude, result.longitude, query) if result else None
86
+ self._cache[query] = place
87
+ return place
File without changes