Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
cb5d4c0
Bump black from 26.1.0 to 26.3.1
dependabot[bot] Mar 12, 2026
8c23cdd
Bump requests from 2.32.5 to 2.33.0
dependabot[bot] Mar 26, 2026
3a0e9f5
Bump pygments from 2.19.2 to 2.20.0
dependabot[bot] Mar 30, 2026
7286cdf
Bump pillow from 12.1.1 to 12.2.0
dependabot[bot] Apr 13, 2026
417c023
Bump pytest from 9.0.2 to 9.0.3
dependabot[bot] Apr 14, 2026
dfbfba2
Bump python-dotenv from 1.2.1 to 1.2.2
dependabot[bot] Apr 21, 2026
f91b3fd
Bump urllib3 from 2.6.3 to 2.7.0
dependabot[bot] May 11, 2026
42e7f7c
Bump idna from 3.11 to 3.15
dependabot[bot] May 19, 2026
0d752e0
Bump aiohttp from 3.13.3 to 3.14.0
dependabot[bot] Jun 3, 2026
a0fee98
Merge remote-tracking branch 'origin/dependabot/uv/idna-3.15' into sw…
soodoku Oct 21, 2026
f9d8e53
Merge remote-tracking branch 'origin/dependabot/uv/urllib3-2.7.0' int…
soodoku Oct 21, 2026
b9a8bf8
Merge remote-tracking branch 'origin/dependabot/uv/python-dotenv-1.2.…
soodoku Oct 21, 2026
e2b4ae3
Merge remote-tracking branch 'origin/dependabot/uv/pytest-9.0.3' into…
soodoku Oct 21, 2026
3eccb8c
Merge remote-tracking branch 'origin/dependabot/uv/pillow-12.2.0' int…
soodoku Oct 21, 2026
f50d106
Merge remote-tracking branch 'origin/dependabot/uv/pygments-2.20.0' i…
soodoku Oct 21, 2026
060d29c
Merge remote-tracking branch 'origin/dependabot/uv/requests-2.33.0' i…
soodoku Oct 21, 2026
373dcc6
Merge remote-tracking branch 'origin/dependabot/uv/black-26.3.1' into…
soodoku Oct 21, 2026
c781d2a
Test Street View processing offline and fix odd-width panorama crops
soodoku Oct 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: CI

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
python-version: ['3.11', '3.14']
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
python-version: ${{ matrix.python-version }}
- run: uv sync --locked --all-extras
- run: uv run black --check src tests
- run: uv run isort --check-only src tests
- run: uv run flake8 --max-line-length=88 --extend-ignore=E203 src tests
- run: uv run pytest -q
- name: Test the installed wheel
run: |
uv build
uv venv /tmp/wheel-test
uv pip install --python /tmp/wheel-test/bin/python dist/*.whl pytest
cd /tmp
/tmp/wheel-test/bin/python -m pytest --import-mode=importlib "$GITHUB_WORKSPACE/tests" -q
/tmp/wheel-test/bin/gsview --help
15 changes: 9 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,23 @@ version = "0.1.0"
description = "Street View sampling and download for Mumbai, Navi Mumbai, and Delhi"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.10"
requires-python = ">=3.11"
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"geo-sampling>=0.2.0",
"requests>=2.31.0",
"pandas>=2.0.0",
"pillow>=10.0.0",
"pillow>=13.0.0",
"tqdm>=4.65.0",
"click>=8.1.0",
"python-dotenv>=1.0.0",
"python-dotenv>=1.2.2",
"folium>=0.15.0",
"streetlevel>=0.12.5",
"seaborn>=0.13.0",
Expand All @@ -35,7 +34,7 @@ dependencies = [

[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"pytest>=9.0.3",
"black>=24.0.0",
"isort>=5.12.0",
"flake8>=7.0.0",
Expand All @@ -52,7 +51,7 @@ where = ["src"]

[tool.black]
line-length = 88
target-version = ["py310"]
target-version = ["py311"]

[tool.isort]
profile = "black"
Expand All @@ -61,3 +60,7 @@ line_length = 88
[tool.flake8]
max-line-length = 88
extend-ignore = ["E203"]

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--strict-config --strict-markers"
7 changes: 4 additions & 3 deletions src/gsview/streetview.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,9 +376,10 @@ def _crop_panorama(
crop_h = crop_w

left = center_x - crop_w // 2
right = center_x + crop_w // 2
top = max(0, center_y - crop_h // 2)
bottom = min(h, center_y + crop_h // 2)
right = left + crop_w
raw_top = center_y - crop_h // 2
top = max(0, raw_top)
bottom = min(h, raw_top + crop_h)

if left < 0 or right > w:
if left < 0:
Expand Down
151 changes: 151 additions & 0 deletions tests/test_streetview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""Exercise HTTP handling, panorama cropping, and batch output offline."""

from io import BytesIO
from types import SimpleNamespace
from unittest.mock import Mock

import pandas as pd
import pytest
import requests
from PIL import Image

from gsview import downloader, streetview


@pytest.fixture(autouse=True)
def forbid_network(monkeypatch):
def unexpected(*args, **kwargs):
raise AssertionError("Tests must not make live requests")

monkeypatch.setattr(requests.sessions.Session, "request", unexpected)


@pytest.mark.parametrize("status,covered", [("OK", True), ("ZERO_RESULTS", False)])
def test_metadata_status_controls_coverage(monkeypatch, status, covered):
response = Mock()
response.json.return_value = {
"status": status,
"pano_id": "pano",
"date": "2026-01",
}
get = Mock(return_value=response)
monkeypatch.setattr(streetview.requests, "get", get)

result = streetview.StreetViewClient(api_key="test", rate_limit=0).check_coverage(
19, 73
)

assert result.has_coverage is covered
assert result.status == status
assert result.pano_id == "pano"
assert result.capture_date == "2026-01"
assert get.call_args.kwargs["params"]["location"] == "19,73"
response.raise_for_status.assert_called_once()


def test_download_rejects_non_image_response(monkeypatch, tmp_path):
response = Mock(headers={"Content-Type": "text/html"})
monkeypatch.setattr(streetview.requests, "get", Mock(return_value=response))
target = tmp_path / "bad.jpg"

result = streetview.StreetViewClient(api_key="test", rate_limit=0).download_image(
19, 73, 90, target
)

assert not result.success
assert "Unexpected content type" in result.error
assert not target.exists()


def test_download_writes_image_bytes(monkeypatch, tmp_path):
image = BytesIO()
Image.new("RGB", (8, 8), "red").save(image, format="JPEG")
response = Mock(headers={"Content-Type": "image/jpeg"}, content=image.getvalue())
monkeypatch.setattr(streetview.requests, "get", Mock(return_value=response))
target = tmp_path / "nested" / "good.jpg"

result = streetview.StreetViewClient(api_key="test", rate_limit=0).download_image(
19, 73, 90, target
)

assert result.success
assert target.read_bytes() == image.getvalue()


def test_request_failure_is_reported(monkeypatch, tmp_path):
monkeypatch.setattr(
streetview.requests, "get", Mock(side_effect=requests.Timeout("timed out"))
)
result = streetview.StreetViewClient(api_key="test", rate_limit=0).download_image(
19, 73, 90, tmp_path / "missing.jpg"
)
assert not result.success
assert result.error == "timed out"


@pytest.mark.parametrize("heading", [0, 180, 359])
@pytest.mark.parametrize("fov", [90, 91])
def test_panorama_crop_has_exact_width_across_seam(heading, fov):
image = Image.new("RGB", (360, 180))
for x in range(360):
image.paste((x % 256, 10, 20), (x, 0, x + 1, 180))

cropped = streetview._crop_panorama(image, heading, 0, fov)

assert cropped.size == (fov, fov)
left = heading - fov // 2
assert [cropped.getpixel((x, 0)) for x in range(fov)] == [
((left + x) % 360 % 256, 10, 20) for x in range(fov)
]


def test_high_resolution_batch_downloads_panorama_once(monkeypatch, tmp_path):
monkeypatch.setattr(
streetview.sv,
"find_panorama_by_id",
Mock(return_value=SimpleNamespace(lat=19, lon=73)),
)
get = Mock(return_value=Image.new("RGB", (360, 180), "blue"))
monkeypatch.setattr(streetview.sv, "get_panorama", get)

results = streetview.download_location_hires(
"pano", "place", tmp_path, headings=[0, 90]
)

get.assert_called_once()
assert len(results) == 2
assert all(result.success for result in results)
assert len(list(tmp_path.glob("*.jpg"))) == 2


def test_missing_panorama_id_never_calls_download(monkeypatch, tmp_path):
download = Mock(side_effect=AssertionError("missing panorama must not download"))
monkeypatch.setattr(downloader, "download_location_hires", download)
locations = pd.DataFrame(
[{"location_id": "a", "city": "Delhi", "lat": 19, "lon": 73, "pano_id": pd.NA}]
)

results = downloader.download_images_hires_batch(
locations, tmp_path, headings=[0, 90]
)

assert len(results) == 2
assert not results["success"].any()
download.assert_not_called()


def test_annotation_csv_excludes_failed_downloads(tmp_path):
common = {"city": "Delhi", "lat": 19, "lon": 73, "heading": 0, "pitch": 0}
downloads = pd.DataFrame(
[
{**common, "location_id": "a", "image_path": "a.jpg", "success": True},
{**common, "location_id": "b", "image_path": None, "success": False},
]
)
path = tmp_path / "annotations.csv"

result = downloader.generate_annotation_csv(downloads, path)

assert result["location_id"].tolist() == ["a"]
assert result["annotation_id"].tolist() == ["ann_000000"]
assert pd.read_csv(path)["location_id"].tolist() == ["a"]
Loading
Loading