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.
- timeplace-0.1.0/PKG-INFO +165 -0
- timeplace-0.1.0/README.md +153 -0
- timeplace-0.1.0/pyproject.toml +50 -0
- timeplace-0.1.0/pyproject.toml.orig +42 -0
- timeplace-0.1.0/src/timeplace/__init__.py +13 -0
- timeplace-0.1.0/src/timeplace/core.py +161 -0
- timeplace-0.1.0/src/timeplace/geo.py +87 -0
- timeplace-0.1.0/src/timeplace/py.typed +0 -0
timeplace-0.1.0/PKG-INFO
ADDED
|
@@ -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
|