random-streetview 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.
- random_streetview-0.1.0/.gitignore +10 -0
- random_streetview-0.1.0/LICENSE +21 -0
- random_streetview-0.1.0/PKG-INFO +98 -0
- random_streetview-0.1.0/README.md +71 -0
- random_streetview-0.1.0/pyproject.toml +54 -0
- random_streetview-0.1.0/src/random_streetview/__init__.py +180 -0
- random_streetview-0.1.0/src/random_streetview/py.typed +0 -0
- random_streetview-0.1.0/tests/test_random_streetview.py +126 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AngLaboratory
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: random-streetview
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: One call, one random Google Street View panorama
|
|
5
|
+
Project-URL: Homepage, https://github.com/AngLaboratory/random-streetview
|
|
6
|
+
Project-URL: Source, https://github.com/AngLaboratory/random-streetview
|
|
7
|
+
Project-URL: Issues, https://github.com/AngLaboratory/random-streetview/issues
|
|
8
|
+
Author-email: AngLaboratory <anglaboratory@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: geoguessr,google-street-view,panoid,panorama,random,street-view,streetview
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Requires-Dist: requests>=2.25
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# random-streetview
|
|
29
|
+
|
|
30
|
+
[](https://pypi.org/project/random-streetview/)
|
|
31
|
+
[](https://pypi.org/project/random-streetview/)
|
|
32
|
+
|
|
33
|
+
One call, one random Google Street View panorama.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install random-streetview
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from random_streetview import random_panorama
|
|
41
|
+
|
|
42
|
+
pano = random_panorama()
|
|
43
|
+
|
|
44
|
+
pano.id # 'ptFCsB5aFyaWDDlpbCliVw'
|
|
45
|
+
pano.lon # 2.2954822
|
|
46
|
+
pano.lat # 48.8583758
|
|
47
|
+
pano.url # 'https://www.google.com/maps/@?api=1&map_action=pano&pano=...'
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`Panorama` is a named tuple, so `panoid, lon, lat = random_panorama()` works too.
|
|
51
|
+
|
|
52
|
+
Nothing raises on a network or parsing failure — you get `None` once the
|
|
53
|
+
attempts are used up.
|
|
54
|
+
|
|
55
|
+
## Timeouts
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
random_panorama(max_retry=10, timeout=3.0) # the defaults
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`timeout` is per request, in seconds, and is raised to **0.5** if you ask for
|
|
62
|
+
less. A single call budgets at most **30 seconds** of timeout across its
|
|
63
|
+
attempts, so `max_retry` is an upper bound rather than a promise: at
|
|
64
|
+
`timeout=10.0` you get 3 attempts, not 10. The default pairing is sized to
|
|
65
|
+
reach all 10. Time actually spent on the wire does not count against the
|
|
66
|
+
budget — only the timeouts do.
|
|
67
|
+
|
|
68
|
+
## How it works, and why it can break
|
|
69
|
+
|
|
70
|
+
Picking a coordinate at random mostly lands you in the ocean, so candidates
|
|
71
|
+
come from [randomstreetview.com][rsv] and [wandery.it][wandery], which keep
|
|
72
|
+
lists of places known to have coverage. Each candidate is then resolved to a
|
|
73
|
+
panorama id through `GeoPhotoService.SingleImageSearch`, an **undocumented
|
|
74
|
+
internal Google Maps endpoint**.
|
|
75
|
+
|
|
76
|
+
That means no API key — and no guarantees. The scraped sites will change their
|
|
77
|
+
HTML, and Google can alter or start refusing that endpoint at any time; when
|
|
78
|
+
that happens this library returns `None` rather than breaking your program. If
|
|
79
|
+
you need something dependable, use the official
|
|
80
|
+
[Street View Static API metadata endpoint][meta] with a key instead (metadata
|
|
81
|
+
requests are not billed).
|
|
82
|
+
|
|
83
|
+
Requests are not rate-limited for you. Space out your calls.
|
|
84
|
+
|
|
85
|
+
## Development
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
uv sync
|
|
89
|
+
uv run pytest # fully offline; network calls are monkeypatched
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT
|
|
95
|
+
|
|
96
|
+
[rsv]: https://randomstreetview.com/
|
|
97
|
+
[wandery]: https://www.wandery.it/
|
|
98
|
+
[meta]: https://developers.google.com/maps/documentation/streetview/metadata
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# random-streetview
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/random-streetview/)
|
|
4
|
+
[](https://pypi.org/project/random-streetview/)
|
|
5
|
+
|
|
6
|
+
One call, one random Google Street View panorama.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install random-streetview
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from random_streetview import random_panorama
|
|
14
|
+
|
|
15
|
+
pano = random_panorama()
|
|
16
|
+
|
|
17
|
+
pano.id # 'ptFCsB5aFyaWDDlpbCliVw'
|
|
18
|
+
pano.lon # 2.2954822
|
|
19
|
+
pano.lat # 48.8583758
|
|
20
|
+
pano.url # 'https://www.google.com/maps/@?api=1&map_action=pano&pano=...'
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`Panorama` is a named tuple, so `panoid, lon, lat = random_panorama()` works too.
|
|
24
|
+
|
|
25
|
+
Nothing raises on a network or parsing failure — you get `None` once the
|
|
26
|
+
attempts are used up.
|
|
27
|
+
|
|
28
|
+
## Timeouts
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
random_panorama(max_retry=10, timeout=3.0) # the defaults
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`timeout` is per request, in seconds, and is raised to **0.5** if you ask for
|
|
35
|
+
less. A single call budgets at most **30 seconds** of timeout across its
|
|
36
|
+
attempts, so `max_retry` is an upper bound rather than a promise: at
|
|
37
|
+
`timeout=10.0` you get 3 attempts, not 10. The default pairing is sized to
|
|
38
|
+
reach all 10. Time actually spent on the wire does not count against the
|
|
39
|
+
budget — only the timeouts do.
|
|
40
|
+
|
|
41
|
+
## How it works, and why it can break
|
|
42
|
+
|
|
43
|
+
Picking a coordinate at random mostly lands you in the ocean, so candidates
|
|
44
|
+
come from [randomstreetview.com][rsv] and [wandery.it][wandery], which keep
|
|
45
|
+
lists of places known to have coverage. Each candidate is then resolved to a
|
|
46
|
+
panorama id through `GeoPhotoService.SingleImageSearch`, an **undocumented
|
|
47
|
+
internal Google Maps endpoint**.
|
|
48
|
+
|
|
49
|
+
That means no API key — and no guarantees. The scraped sites will change their
|
|
50
|
+
HTML, and Google can alter or start refusing that endpoint at any time; when
|
|
51
|
+
that happens this library returns `None` rather than breaking your program. If
|
|
52
|
+
you need something dependable, use the official
|
|
53
|
+
[Street View Static API metadata endpoint][meta] with a key instead (metadata
|
|
54
|
+
requests are not billed).
|
|
55
|
+
|
|
56
|
+
Requests are not rate-limited for you. Space out your calls.
|
|
57
|
+
|
|
58
|
+
## Development
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
uv sync
|
|
62
|
+
uv run pytest # fully offline; network calls are monkeypatched
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
|
|
67
|
+
MIT
|
|
68
|
+
|
|
69
|
+
[rsv]: https://randomstreetview.com/
|
|
70
|
+
[wandery]: https://www.wandery.it/
|
|
71
|
+
[meta]: https://developers.google.com/maps/documentation/streetview/metadata
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "random-streetview"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "One call, one random Google Street View panorama"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "AngLaboratory", email = "anglaboratory@gmail.com" }]
|
|
14
|
+
keywords = [
|
|
15
|
+
"streetview",
|
|
16
|
+
"street-view",
|
|
17
|
+
"google-street-view",
|
|
18
|
+
"random",
|
|
19
|
+
"panorama",
|
|
20
|
+
"panoid",
|
|
21
|
+
"geoguessr",
|
|
22
|
+
]
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Development Status :: 4 - Beta",
|
|
25
|
+
"Intended Audience :: Developers",
|
|
26
|
+
"Operating System :: OS Independent",
|
|
27
|
+
"Programming Language :: Python :: 3",
|
|
28
|
+
"Programming Language :: Python :: 3.9",
|
|
29
|
+
"Programming Language :: Python :: 3.10",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Programming Language :: Python :: 3.12",
|
|
32
|
+
"Programming Language :: Python :: 3.13",
|
|
33
|
+
"Programming Language :: Python :: 3.14",
|
|
34
|
+
"Topic :: Multimedia :: Graphics",
|
|
35
|
+
"Typing :: Typed",
|
|
36
|
+
]
|
|
37
|
+
dependencies = ["requests>=2.25"]
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://github.com/AngLaboratory/random-streetview"
|
|
41
|
+
Source = "https://github.com/AngLaboratory/random-streetview"
|
|
42
|
+
Issues = "https://github.com/AngLaboratory/random-streetview/issues"
|
|
43
|
+
|
|
44
|
+
[dependency-groups]
|
|
45
|
+
dev = ["pytest>=8.0"]
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["src/random_streetview"]
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.sdist]
|
|
51
|
+
include = ["src/", "tests/", "README.md", "LICENSE"]
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""One call, one random Google Street View panorama.
|
|
2
|
+
|
|
3
|
+
>>> from random_streetview import random_panorama
|
|
4
|
+
>>> random_panorama()
|
|
5
|
+
Panorama(id='ptFCsB5aFyaWDDlpbCliVw', lon=2.2954822, lat=48.8583758)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import random
|
|
12
|
+
import re
|
|
13
|
+
from typing import NamedTuple, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
__all__ = ["Panorama", "random_panorama"]
|
|
19
|
+
|
|
20
|
+
# Per-request timeout: the default, and the floor any caller is held to.
|
|
21
|
+
_DEFAULT_TIMEOUT = 3.0
|
|
22
|
+
_MIN_TIMEOUT = 0.5
|
|
23
|
+
|
|
24
|
+
# Total timeout a single call may budget across its attempts. Measured in
|
|
25
|
+
# timeout units rather than wall clock, so time actually spent on the wire
|
|
26
|
+
# does not count against it.
|
|
27
|
+
_TIMEOUT_BUDGET = 30.0
|
|
28
|
+
|
|
29
|
+
_USER_AGENT = (
|
|
30
|
+
"random-streetview/0.1.0 "
|
|
31
|
+
"(+https://github.com/AngLaboratory/random-streetview)"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Google's panorama ids are always this long; anything else is a miss.
|
|
35
|
+
_PANOID_LENGTH = 22
|
|
36
|
+
|
|
37
|
+
# Undocumented internal Maps endpoint -- no API key, no guarantees.
|
|
38
|
+
_SEARCH_URL = (
|
|
39
|
+
"https://maps.googleapis.com/maps/api/js/GeoPhotoService.SingleImageSearch"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Picking a point at random mostly lands you in the ocean, so candidates come
|
|
43
|
+
# from sites that keep their own lists of places known to have coverage.
|
|
44
|
+
_RSV_URL = "https://randomstreetview.com/"
|
|
45
|
+
_RSV_RE = re.compile(r"randomLocations\.all\s*=\s*(\[\{.*?\}\]);", re.S)
|
|
46
|
+
|
|
47
|
+
_WANDERY_URL = (
|
|
48
|
+
"https://www.wandery.it/random-street/random-streetview-lateral-thinking.php"
|
|
49
|
+
)
|
|
50
|
+
_WANDERY_RE = re.compile(r"!2m2!1d([-\d.]+)!2d([-\d.]+)", re.S)
|
|
51
|
+
|
|
52
|
+
_LonLat = Tuple[Optional[float], Optional[float]]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Panorama(NamedTuple):
|
|
56
|
+
"""A Street View panorama and where it is."""
|
|
57
|
+
|
|
58
|
+
id: str
|
|
59
|
+
lon: float
|
|
60
|
+
lat: float
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def url(self) -> str:
|
|
64
|
+
"""A google.com/maps link that opens this panorama."""
|
|
65
|
+
return (
|
|
66
|
+
"https://www.google.com/maps/@"
|
|
67
|
+
f"?api=1&map_action=pano&pano={self.id}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _get(url: str, timeout: float) -> Optional[str]:
|
|
72
|
+
try:
|
|
73
|
+
response = requests.get(
|
|
74
|
+
url, headers={"User-Agent": _USER_AGENT}, timeout=timeout
|
|
75
|
+
)
|
|
76
|
+
response.raise_for_status()
|
|
77
|
+
return response.text
|
|
78
|
+
except Exception:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _from_randomstreetview(timeout: float) -> _LonLat:
|
|
83
|
+
body = _get(_RSV_URL, timeout)
|
|
84
|
+
if body is None:
|
|
85
|
+
return None, None
|
|
86
|
+
|
|
87
|
+
match = _RSV_RE.search(body)
|
|
88
|
+
if not match:
|
|
89
|
+
return None, None
|
|
90
|
+
try:
|
|
91
|
+
locations = json.loads(match.group(1))
|
|
92
|
+
point = random.choice(locations)
|
|
93
|
+
return float(point["lng"]), float(point["lat"])
|
|
94
|
+
except (ValueError, KeyError, TypeError, IndexError):
|
|
95
|
+
return None, None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _from_wandery(timeout: float) -> _LonLat:
|
|
99
|
+
body = _get(_WANDERY_URL, timeout)
|
|
100
|
+
if body is None:
|
|
101
|
+
return None, None
|
|
102
|
+
|
|
103
|
+
match = _WANDERY_RE.search(body)
|
|
104
|
+
if not match:
|
|
105
|
+
return None, None
|
|
106
|
+
try:
|
|
107
|
+
return float(match.group(2)), float(match.group(1))
|
|
108
|
+
except ValueError:
|
|
109
|
+
return None, None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _random_lonlat(timeout: float) -> _LonLat:
|
|
113
|
+
for source in (_from_randomstreetview, _from_wandery):
|
|
114
|
+
lon, lat = source(timeout)
|
|
115
|
+
if lon is not None and lat is not None:
|
|
116
|
+
return lon, lat
|
|
117
|
+
return None, None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _panoid_at(lon: float, lat: float, timeout: float) -> Optional[str]:
|
|
121
|
+
body = _get(
|
|
122
|
+
f"{_SEARCH_URL}?pb=!1m5!1sapiv3!5sUS!11m2!1m1!1b0"
|
|
123
|
+
f"!2m4!1m2!3d{lat}!4d{lon}!2d50!3m10"
|
|
124
|
+
"!2m2!1sen!2sGB!9m1!1e2!11m4!1m3!1e2!2b1!3e2!4m10!1e1!1e2!1e3!1e4"
|
|
125
|
+
"!1e8!1e6!5m1!1e2!6m1!1e2&callback=callbackfunc",
|
|
126
|
+
timeout,
|
|
127
|
+
)
|
|
128
|
+
if body is None:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
# The id is the first quoted string in the JSONP payload.
|
|
132
|
+
start = body.find('"')
|
|
133
|
+
end = body.find('"', start + 1) if start != -1 else -1
|
|
134
|
+
if start == -1 or end == -1:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
panoid = body[start + 1 : end]
|
|
138
|
+
# "generic" is what the endpoint returns when it has nothing to offer.
|
|
139
|
+
if panoid == "generic" or len(panoid) != _PANOID_LENGTH:
|
|
140
|
+
return None
|
|
141
|
+
return panoid
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def random_panorama(
|
|
145
|
+
*, max_retry: int = 10, timeout: float = _DEFAULT_TIMEOUT
|
|
146
|
+
) -> Optional[Panorama]:
|
|
147
|
+
"""A random Street View panorama from somewhere on Earth.
|
|
148
|
+
|
|
149
|
+
``timeout`` is the per-request timeout in seconds, raised to
|
|
150
|
+
:data:`_MIN_TIMEOUT` if you ask for less. Attempts stop early once
|
|
151
|
+
``timeout`` times the attempts made would exceed the 30 second budget, so
|
|
152
|
+
``max_retry`` is an upper bound rather than a promise -- the defaults are
|
|
153
|
+
sized to reach all 10.
|
|
154
|
+
|
|
155
|
+
Returns ``None`` when the attempts are used up without a hit. Nothing here
|
|
156
|
+
raises on a network or parsing failure.
|
|
157
|
+
"""
|
|
158
|
+
if max_retry < 1:
|
|
159
|
+
raise ValueError("max_retry must be at least 1")
|
|
160
|
+
|
|
161
|
+
timeout = max(timeout, _MIN_TIMEOUT)
|
|
162
|
+
if timeout > _TIMEOUT_BUDGET:
|
|
163
|
+
raise ValueError(
|
|
164
|
+
"timeout must not exceed the {:.0f}s budget".format(_TIMEOUT_BUDGET)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
budgeted = 0.0
|
|
168
|
+
for _ in range(max_retry):
|
|
169
|
+
if budgeted + timeout > _TIMEOUT_BUDGET:
|
|
170
|
+
break
|
|
171
|
+
budgeted += timeout
|
|
172
|
+
|
|
173
|
+
lon, lat = _random_lonlat(timeout)
|
|
174
|
+
if lon is None or lat is None:
|
|
175
|
+
continue
|
|
176
|
+
panoid = _panoid_at(lon, lat, timeout)
|
|
177
|
+
if panoid is not None:
|
|
178
|
+
return Panorama(panoid, lon, lat)
|
|
179
|
+
|
|
180
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Offline tests -- every network call is monkeypatched."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
import random_streetview as rsv
|
|
6
|
+
from random_streetview import Panorama, random_panorama
|
|
7
|
+
|
|
8
|
+
_RSV_BODY = 'var x; randomLocations.all = [{"lat":48.8584,"lng":2.2945}];'
|
|
9
|
+
_WANDERY_BODY = "?pb=!2m2!1d48.8584!2d2.2945!3m1"
|
|
10
|
+
_JSONP = 'callbackfunc([1,"ptFCsB5aFyaWDDlpbCliVw",[null]])'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_panorama_unpacks_and_builds_a_url():
|
|
14
|
+
pano = Panorama("a" * 22, 2.2945, 48.8584)
|
|
15
|
+
assert (pano.id, pano.lon, pano.lat) == tuple(pano)
|
|
16
|
+
assert pano.url.endswith("pano=" + "a" * 22)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_max_retry_must_be_positive():
|
|
20
|
+
with pytest.raises(ValueError):
|
|
21
|
+
random_panorama(max_retry=0)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_timeout_is_raised_to_the_floor(monkeypatch):
|
|
25
|
+
seen = []
|
|
26
|
+
|
|
27
|
+
def fake_get(url, timeout):
|
|
28
|
+
seen.append(timeout)
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
monkeypatch.setattr(rsv, "_get", fake_get)
|
|
32
|
+
random_panorama(max_retry=1, timeout=0.01)
|
|
33
|
+
assert seen and all(t == rsv._MIN_TIMEOUT for t in seen)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_timeout_larger_than_the_budget_is_rejected():
|
|
37
|
+
with pytest.raises(ValueError):
|
|
38
|
+
random_panorama(timeout=rsv._TIMEOUT_BUDGET + 1)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_attempts_stop_at_the_budget(monkeypatch):
|
|
42
|
+
attempts = []
|
|
43
|
+
|
|
44
|
+
def fake_get(url, timeout):
|
|
45
|
+
if url.startswith("https://randomstreetview"):
|
|
46
|
+
attempts.append(url)
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
monkeypatch.setattr(rsv, "_get", fake_get)
|
|
50
|
+
# 10s per request leaves room for 3 attempts inside the 30s budget,
|
|
51
|
+
# not the 10 that max_retry asks for.
|
|
52
|
+
assert random_panorama(max_retry=10, timeout=10.0) is None
|
|
53
|
+
assert len(attempts) == 3
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_defaults_reach_every_retry(monkeypatch):
|
|
57
|
+
attempts = []
|
|
58
|
+
|
|
59
|
+
def fake_get(url, timeout):
|
|
60
|
+
if url.startswith("https://randomstreetview"):
|
|
61
|
+
attempts.append(url)
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
monkeypatch.setattr(rsv, "_get", fake_get)
|
|
65
|
+
assert random_panorama() is None
|
|
66
|
+
assert len(attempts) == 10
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_happy_path(monkeypatch):
|
|
70
|
+
def fake_get(url, timeout):
|
|
71
|
+
return _RSV_BODY if url.startswith("https://randomstreetview") else _JSONP
|
|
72
|
+
|
|
73
|
+
monkeypatch.setattr(rsv, "_get", fake_get)
|
|
74
|
+
assert random_panorama() == Panorama("ptFCsB5aFyaWDDlpbCliVw", 2.2945, 48.8584)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_falls_back_to_the_second_source(monkeypatch):
|
|
78
|
+
def fake_get(url, timeout):
|
|
79
|
+
if url.startswith("https://randomstreetview"):
|
|
80
|
+
return None
|
|
81
|
+
if url.startswith("https://www.wandery"):
|
|
82
|
+
return _WANDERY_BODY
|
|
83
|
+
return _JSONP
|
|
84
|
+
|
|
85
|
+
monkeypatch.setattr(rsv, "_get", fake_get)
|
|
86
|
+
pano = random_panorama()
|
|
87
|
+
assert pano is not None
|
|
88
|
+
assert (pano.lon, pano.lat) == (2.2945, 48.8584)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_returns_none_when_everything_fails(monkeypatch):
|
|
92
|
+
monkeypatch.setattr(rsv, "_get", lambda url, timeout: None)
|
|
93
|
+
assert random_panorama(max_retry=3) is None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_rejects_a_generic_panoid(monkeypatch):
|
|
97
|
+
def fake_get(url, timeout):
|
|
98
|
+
if url.startswith("https://randomstreetview"):
|
|
99
|
+
return _RSV_BODY
|
|
100
|
+
return 'callbackfunc([1,"generic",[null]])'
|
|
101
|
+
|
|
102
|
+
monkeypatch.setattr(rsv, "_get", fake_get)
|
|
103
|
+
assert random_panorama(max_retry=2) is None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_rejects_a_wrong_length_panoid(monkeypatch):
|
|
107
|
+
def fake_get(url, timeout):
|
|
108
|
+
if url.startswith("https://randomstreetview"):
|
|
109
|
+
return _RSV_BODY
|
|
110
|
+
return 'callbackfunc([1,"tooshort",[null]])'
|
|
111
|
+
|
|
112
|
+
monkeypatch.setattr(rsv, "_get", fake_get)
|
|
113
|
+
assert random_panorama(max_retry=2) is None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_survives_an_unparseable_source(monkeypatch):
|
|
117
|
+
monkeypatch.setattr(rsv, "_get", lambda url, timeout: "not what we expected")
|
|
118
|
+
assert random_panorama(max_retry=2) is None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_get_swallows_network_errors(monkeypatch):
|
|
122
|
+
def boom(*args, **kwargs):
|
|
123
|
+
raise OSError("no network")
|
|
124
|
+
|
|
125
|
+
monkeypatch.setattr(rsv.requests, "get", boom)
|
|
126
|
+
assert rsv._get("https://example.com", 1.0) is None
|