Skip to content

Add OpenStreetMap querier with unit and smoke tests - #59

Open
Lolli-AK wants to merge 1 commit into
mainfrom
ak_querier
Open

Lolli-AK wants to merge 1 commit into
mainfrom
ak_querier

Conversation

@Lolli-AK

@Lolli-AK Lolli-AK commented Sep 8, 2026

Copy link
Copy Markdown

Fetches clothing-related locations from the Overpass API:

  • Dedupes by "{type}/{id}" so each source list has no duplicate
    entries, per MergeProcessor.match()'s assumption

  • Sets a User-Agent (Overpass returns 406 without one) and retries after 429/502/503/504 error responses

  • Adds unit tests and a smoke test against live Overpass

@Lolli-AK
Lolli-AK requested a review from a team September 8, 2026 13:06
return self._to_raw_locations(elements)

def _build_query(self) -> str:
s, w, n, e = self.bbox

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since most queriers will support boundaries, it might be good to define a class to represent the boundaries. It'll help prevent mix-ups if another querier gets the order wrong.

def _run(self, query: str, attempts: int = 3) -> list[dict]:
for attempt in range(attempts):
response = httpx.post(
OVERPASS_URL, data={"data": query},

@cnapolit cnapolit Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's good practice for each arg to live on it's own line when they're broken up like this

@cnapolit cnapolit Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good job making sure this included tests. It's very easy to exclude them when no one is forcing you to.

@cnapolit cnapolit left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good overall, nice work!

The Only changes I think are needed include:

  • separating the unit tests from the smoke tests since I believe that's what we agreed on during the last hackathon
  • prepending the ticket number to the commit message i.e. #16 Add OpenStreetMap querier with unit and smoke tests

Everything else is optional.

@@ -0,0 +1,175 @@
"""Tests for the OpenStreetMap querier.

Unit tests run offline and cover query building and RawLocation mapping.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit(pick) - a unit test that makes a web request imo isn't a unit test.
Probably fine to omit that:

Unit tests run offline and cover query building and RawLocation mapping.


def test_build_query_exact_output():
q = OpenStreetMapQuerier(tag_filters=['["shop"="tailor"]'], bbox=(1.0, 2.0, 3.0, 4.0), timeout_s=25)
assert q._build_query() == '[out:json][timeout:25];(nwr["shop"="tailor"](1.0,2.0,3.0,4.0););out center;'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not normally a fan of writing unit tests for private methods (or having public methods that are only externally called by unit tests).

However, since the alternative to is use mocking & over-complicate things, it's fine as is.
This is more for future reference.

def test_payload_is_preserved_unmodified(querier):
result = querier._to_raw_locations([WAY_WITH_CENTER])
# Interpretation belongs to the normalizer, so the raw element passes through intact.
assert result[0].payload == WAY_WITH_CENTER

@cnapolit cnapolit Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

whenever you're checking an index, it's good to assert the result has the expected length (similar to other tests)

RETRYABLE = {429, 502, 503, 504}


# based on coordinates set in repair-00.json in /data-explorations

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably best to not reference files outside of the ETL pipeline.
Can lead to confusion if they end up being removed.

headers={"User-Agent": USER_AGENT},
)
if response.status_code == 200:
return response.json()["elements"]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit - it's sometimes nice to separate method calls from indexing:

body = response.json()
return body["elements"]


@pytest.mark.network
def test_smoke_fetch_against_live_overpass(querier):
results = querier.fetch()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may throw. Might be good to catch the exception & evaluate why.

assert all(i.split("/")[0] in {"node", "way", "relation"} for i in ids)

# Every element must be placeable: nodes carry lat/lon, ways/relations carry center.
for raw in results:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feel like were iterating on results in different places we we could iterate once & validate in a separate method:

for raw in results:
  validate_result(raw)
  # or even break it up into a couple methods
  validate_id(raw.data_source_id)
  validate_tags(raw.payload)

assert ("lat" in p and "lon" in p) or "center" in p, f"no coordinates: {raw.data_source_id}"

named = [r for r in results if r.payload.get("tags", {}).get("name")]
print(f" {len(named)} named, {len(results) - len(named)} unnamed")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does pytest have some sort of logger? Would give some control over where & when these message would get printed. Would probably label these as debug logs.

if response.status_code == 200:
return response.json()["elements"]
if response.status_code in RETRYABLE and attempt < attempts - 1:
time.sleep(2 ** attempt * 5) # 5s, 10s

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good that we're waiting longer each attempt. Most APIs expect this to some degree.

if response.status_code in RETRYABLE and attempt < attempts - 1:
time.sleep(2 ** attempt * 5) # 5s, 10s
continue
raise RuntimeError(f"Overpass returned {response.status_code}: {response.text[:500]}")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't hurt to include the attempt # & an explanation if there is one. Looking at the httpx documentation, maybe reason_phrase?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants