Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# This workflow runs the repo's CI tests.
# This workflow runs the repo's (local/offline) CI tests.
name: CI

on:
Expand All @@ -7,7 +7,7 @@ on:
pull_request:
branches: [main]
schedule:
- cron: 5 4 * * 1 # every monday at 04:05 UTC
- cron: 30 4 * * 1 # every monday at 04:30 UTC
workflow_dispatch:

concurrency:
Expand Down
46 changes: 46 additions & 0 deletions .github/workflows/test-online-apis.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# This workflow runs live online public web API compatibility tests.
name: Test online public web APIs

on:
schedule:
- cron: 5 4 * * 1 # every monday at 04:05 UTC
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
test_online_apis:
if: ${{ github.repository == 'gboeing/osmnx' }}
name: Public web APIs
runs-on: ubuntu-latest
timeout-minutes: 30

defaults:
run:
shell: bash -elo pipefail {0}

steps:
- name: Checkout repo
uses: actions/checkout@v6

- name: Install uv
uses: astral-sh/setup-uv@v7
with:
cache-dependency-glob: pyproject.toml
enable-cache: true
python-version: '3.14'

- name: Install OSMnx
run: |
uv python pin 3.14
uv sync --all-extras --group test

- name: Test public web APIs
run: uv run pytest -m online --numprocesses=3
env:
OSMNX_RUN_ONLINE_TESTS: '1'
6 changes: 3 additions & 3 deletions osmnx/_overpass.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,16 +446,16 @@ def _overpass_request(data: OrderedDict[str, Any]) -> dict[str, Any]:
response_json
The Overpass API's response.
"""
# resolve url to same IP even if there is server round-robin redirecting
_http._config_dns(settings.overpass_url)

# prepare the Overpass API URL and see if request already exists in cache
url = settings.overpass_url.rstrip("/") + "/interpreter"
prepared_url = str(requests.Request("GET", url, params=data).prepare().url)
cached_response_json = _http._retrieve_from_cache(prepared_url)
if isinstance(cached_response_json, dict):
return cached_response_json

# resolve url to same IP even if there is server round-robin redirecting
_http._config_dns(settings.overpass_url)

# pause then request this URL
pause = _get_overpass_pause(settings.overpass_url)
hostname = _http._hostname_from_url(url)
Expand Down
2 changes: 1 addition & 1 deletion osmnx/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ def circuity_avg(Gu: nx.MultiGraph) -> float | None:
sl_dists_total = sl_dists[~np.isnan(sl_dists)].sum()
try:
return float(edge_length_total(Gu) / sl_dists_total)
except ZeroDivisionError:
except ZeroDivisionError: # pragma: no cover
return None


Expand Down
4 changes: 2 additions & 2 deletions osmnx/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,15 +144,15 @@ def log(
message = f"{ts()} {message}"
message = ud.normalize("NFKD", message).encode("ascii", errors="replace").decode()

try:
try: # pragma: no cover
# print explicitly to terminal in case Jupyter has captured stdout
if getattr(sys.stdout, "_original_stdstream_copy", None) is not None:
# redirect the Jupyter-captured pipe back to original
os.dup2(sys.stdout._original_stdstream_copy, sys.__stdout__.fileno()) # type: ignore[union-attr]
sys.stdout._original_stdstream_copy = None # type: ignore[union-attr]
with redirect_stdout(sys.__stdout__):
print(message, file=sys.__stdout__, flush=True)
except OSError:
except OSError: # pragma: no cover
# handle pytest on Windows raising OSError from sys.__stdout__
print(message, flush=True) # noqa: T201

Expand Down
9 changes: 7 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[build-system]
build-backend = "uv_build"
requires = ["uv_build==0.10.*"]
requires = ["uv_build==0.11.*"]

[project]
authors = [{ name = "Geoff Boeing", email = "boeing@usc.edu" }]
Expand Down Expand Up @@ -61,6 +61,7 @@ Documentation = "https://osmnx.readthedocs.io"

[tool.coverage.report]
exclude_also = ["@overload", "if TYPE_CHECKING:"]
fail_under = 99

[tool.mypy]
cache_dir = "~/.cache/prek/cache/mypy"
Expand All @@ -79,6 +80,10 @@ addopts = ["-ra", "--verbose", "--maxfail=1", "--numprocesses=3", "--dist=loadgr
cache_dir = "~/.cache/pytest"
filterwarnings = ["error", "ignore::UserWarning"]
log_level = "INFO"
markers = [
"offline: deterministic tests that do not make online public web API calls",
"online: tests that make live online public web API calls",
]
minversion = 9
strict = true
testpaths = ["tests"]
Expand Down Expand Up @@ -108,7 +113,7 @@ convention = "numpy"
max-args = 8

[tool.uv]
required-version = "==0.10.*" # match version above and in Dockerfile
required-version = "==0.11.*" # match version above and in Dockerfile

[tool.uv.build-backend]
module-root = ""
201 changes: 201 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
#!/usr/bin/env python
"""Shared pytest fixtures for deterministic OSMnx tests."""

from __future__ import annotations

import json
import os
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Any
from typing import TypeAlias

import matplotlib as mpl
import networkx as nx
import pytest
import requests
from shapely import LineString

mpl.use("Agg")

if TYPE_CHECKING:
from collections.abc import Iterator

LOCATION_POINT = (37.791427, -122.410018)
ADDRESS = "Transamerica Pyramid, 600 Montgomery Street, San Francisco, California, USA"
PLACE = {"city": "Piedmont", "state": "California", "country": "USA"}
TAGS: dict[str, bool | str | list[str]] = {
"landuse": True,
"building": True,
"highway": True,
"amenity": True,
}

_ResponseJson: TypeAlias = dict[str, object] | list[dict[str, object]]
HTTP_OK = 200
HTTP_ERROR = 500


def _drive_graph() -> nx.MultiDiGraph:
import osmnx as ox # noqa: PLC0415

return ox.graph_from_point(
LOCATION_POINT,
dist=500,
network_type="drive",
simplify=False,
retain_all=True,
)


def _toy_graph(*, crs: str = "epsg:4326") -> nx.MultiDiGraph:
G = nx.MultiDiGraph(crs=crs)
G.add_node(1, x=0.0, y=0.0, street_count=1, elevation=0.0)
G.add_node(2, x=1.0, y=0.0, street_count=2, elevation=10.0)
G.add_node(3, x=2.0, y=0.0, street_count=1, elevation=20.0)
G.add_edge(
1,
2,
osmid=10,
length=1.0,
highway="residential",
maxspeed="25 mph",
geometry=LineString([(0, 0), (1, 0)]),
)
G.add_edge(
2,
3,
osmid=11,
length=1.0,
highway=["primary", "secondary"],
maxspeed=["30 mph", "50"],
geometry=LineString([(1, 0), (2, 0)]),
)
return G


class _Response(requests.Response):
def __init__(self, payload: _ResponseJson, *, ok: bool = True, status_code: int = 200) -> None:
super().__init__()
self._payload = payload
self.status_code = status_code if ok or status_code != HTTP_OK else HTTP_ERROR
self.reason = "OK" if ok else "Error"
self._content = json.dumps(payload).encode()
self.url = "https://example.com/api"

def json(self, **kwargs: object) -> _ResponseJson:
del kwargs
return self._payload


drive_graph = _drive_graph
toy_graph = _toy_graph
Response = _Response

HTTP_CACHE_DIR = Path(__file__).parent / "input_data" / "http_cache"


def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
"""
Skip live online tests unless explicitly requested.

Parameters
----------
config
Pytest configuration object.
items
Collected test items.
"""
del config

if os.environ.get("OSMNX_RUN_ONLINE_TESTS"):
return

skip_online = pytest.mark.skip(reason="set OSMNX_RUN_ONLINE_TESTS=1 to run online tests")
for item in items:
if item.get_closest_marker("online"):
item.add_marker(skip_online)


@pytest.fixture(autouse=True)
def _isolate_settings(tmp_path: Path) -> Iterator[None]:
"""
Restore global settings after each test and isolate generated files.

Parameters
----------
tmp_path
Temporary directory unique to the test.

Yields
------
None
Control returns to pytest after each test.
"""
import osmnx as ox # noqa: PLC0415

original_settings = {
name: getattr(ox.settings, name)
for name in dir(ox.settings)
if not name.startswith("_") and name.islower()
}

ox.settings.data_folder = tmp_path / "data"
ox.settings.logs_folder = tmp_path / "logs"
ox.settings.imgs_folder = tmp_path / "imgs"
ox.settings.cache_folder = tmp_path / "cache"
ox.settings.log_console = False
ox.settings.log_file = False
ox.settings.use_cache = True

yield

for name, value in original_settings.items():
setattr(ox.settings, name, value)


@pytest.fixture(autouse=True)
def _block_network(
monkeypatch: pytest.MonkeyPatch,
request: pytest.FixtureRequest,
) -> None:
"""
Prevent accidental live HTTP calls in the default offline suite.

Parameters
----------
monkeypatch
Pytest fixture for temporary object replacement.
request
Pytest request object for the active test.
"""
if request.node.get_closest_marker("online") and os.environ.get("OSMNX_RUN_ONLINE_TESTS"):
return

def _blocked_request(*args: Any, **kwargs: Any) -> None: # noqa: ANN401, ARG001
msg = (
"Network access is blocked in offline tests. Mark the test with "
"`@pytest.mark.online` and set OSMNX_RUN_ONLINE_TESTS=1 to allow it."
)
raise AssertionError(msg)

monkeypatch.setattr(requests, "get", _blocked_request)
monkeypatch.setattr(requests, "post", _blocked_request)


@pytest.fixture
def http_cache() -> Path:
"""
Point OSMnx cache lookups at committed raw HTTP response fixtures.

Returns
-------
pathlib.Path
Directory containing committed raw HTTP cache files.
"""
import osmnx as ox # noqa: PLC0415

ox.settings.cache_folder = HTTP_CACHE_DIR
ox.settings.use_cache = True
ox.settings.overpass_rate_limit = False
return HTTP_CACHE_DIR
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[]
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"results": [
{
"elevation": 10.0,
"location": {}
},
{
"elevation": 11.0,
"location": {}
},
{
"elevation": 12.0,
"location": {}
},
{
"elevation": 13.0,
"location": {}
},
{
"elevation": 14.0,
"location": {}
}
],
"status": "OK"
}
Loading