Skip to content

Add Google Places ETL pipelines - #19

Open
TristanGramsch wants to merge 1 commit into
mainfrom
google-places-etl
Open

Add Google Places ETL pipelines#19
TristanGramsch wants to merge 1 commit into
mainfrom
google-places-etl

Conversation

@TristanGramsch

@TristanGramsch TristanGramsch commented May 6, 2026

Copy link
Copy Markdown
Collaborator

Add ETL support for Google Places repair shops and donation centers.
Implement queriers and normalizers backed by live Google Places search responses.
Add tests using the checked-in sample payloads and verify live API responses locally.

Verification

uv run pytest pipelines/google_places/test_pipeline.py
Live Google Places fetch and normalization succeeded for both pipelines.

@TristanGramsch
TristanGramsch requested a review from a team May 6, 2026 19:14
@TristanGramsch

TristanGramsch commented May 6, 2026

Copy link
Copy Markdown
Collaborator Author

Sorry about the ugly format of the body.


class GooglePlacesRepairQuerier(GooglePlacesQuerier):
def __init__(self, api_key: str | None = None):
super().__init__(api_key=api_key, text_query="shoe repair Boston MA", data_source="google_places_repair")

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.

Should this be hard-coded to shoe repair?

@MattClarke131 MattClarke131 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Overall this is great work! My comments are mostly just rearranging code to follow pattern, but this is 95% there. The tests inspires confidence ^^

Thanks for your volunteer effort! It's great to see data work taking off 😊

Comment on lines +9 to +34
class GooglePlacesRepairNormalizer(BaseNormalizer):
def normalize(self, raw_locations: list[RawLocation]) -> list[NormalizedLocation]:
return [
NormalizedLocation(**normalize_google_place(
raw.payload,
data_source=raw.data_source,
data_source_id=raw.data_source_id,
item_category=ItemCategory.SHOES,
activity=Activity.REPAIR_PAID,
))
for raw in raw_locations
]


class GooglePlacesDonationNormalizer(BaseNormalizer):
def normalize(self, raw_locations: list[RawLocation]) -> list[NormalizedLocation]:
return [
NormalizedLocation(**normalize_google_place(
raw.payload,
data_source=raw.data_source,
data_source_id=raw.data_source_id,
item_category=ItemCategory.CLOTHING,
activity=Activity.DONATION_DROP,
))
for raw in raw_locations
]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

📝 Changes requested: Let's remove these and create a single GooglePlacesNormalizer.

I would follow the pattern you did with GooglePlacesQuerier here: You have a class that takes init parameters and passes those through from the querier to the normalizer.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

When it comes time to run these jobs, we might have something like the following.

  queries = [
      GooglePlacesQuery(
          text_query="shoe repair Boston MA",
          data_source="google_places_repair",
          item_category=ItemCategory.SHOES,
          activity=Activity.REPAIR_PAID
      ),
      GooglePlacesQuery(
          text_query="donation centers Boston MA",
          data_source="google_places_donations",
          item_category=ItemCategory.CLOTHING,
          activity=Activity.DONATION_DROP
      ),
  ]

  for query in queries:
      querier = GooglePlacesQuerier(text_query=query.text_query, data_source=query.data_source)
      normalizer = GooglePlacesNormalizer(item_category=query.item_category, activity=query.activity)
      run(querier, normalizer)

(But this part is out of scope for this ticket. We are just trying to scaffold the pipelines here.)



class GooglePlacesQuerier(BaseQuerier):
def __init__(self, *, api_key: str | None = None, text_query: str, data_source: str):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
def __init__(self, *, api_key: str | None = None, text_query: str, data_source: str):
def __init__(self, *, text_query: str, data_source: str, api_key: str | None = None):

optional parameters should go at the end

Comment thread etl/pipelines/google_places/querier.py
Comment on lines +24 to +25
if not self.api_key:
raise RuntimeError("GOOGLE_API_KEY is required")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nice error handling

Comment on lines +60 to +67
class GooglePlacesRepairQuerier(GooglePlacesQuerier):
def __init__(self, api_key: str | None = None):
super().__init__(api_key=api_key, text_query="shoe repair Boston MA", data_source="google_places_repair")


class GooglePlacesDonationQuerier(GooglePlacesQuerier):
def __init__(self, api_key: str | None = None):
super().__init__(api_key=api_key, text_query="donation centers Boston MA", data_source="google_places_donations")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

📝 Changes Requested: Let's remove these.

They were helpful for me to understand your intentions here, but these individual queries belong in their own dedicated space. We will deal with those later.

See my comment on the normalizer for more context.

Comment on lines +32 to +54
def test_repair_normalizer_maps_google_places_sample():
normalized = GooglePlacesRepairNormalizer().normalize(
_raw_locations("google_places_repair", "repair", "shoe-repair-00.json")
)

assert normalized
assert normalized[0].data_source == "google_places_repair"
assert normalized[0].services[0].activity == Activity.REPAIR_PAID
assert normalized[0].services[0].item_category == ItemCategory.SHOES
assert normalized[0].name == "David's Instant Shoe Repair"
assert normalized[0].address.street == "281 Franklin St, Boston, MA 02110, USA"


def test_donation_normalizer_maps_google_places_sample():
normalized = GooglePlacesDonationNormalizer().normalize(
_raw_locations("google_places_donations", "donation", "donations-00.json")
)

assert normalized
assert normalized[0].data_source == "google_places_donations"
assert normalized[0].services[0].activity == Activity.DONATION_DROP
assert normalized[0].services[0].item_category == ItemCategory.CLOTHING
assert normalized[0].name == "Morgan Memorial Goodwill Industries"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤙 You had a sharp catch here: You're testing the normalizer, but not the querier. It is tricky to test the querier, because it depends on google's api.

📝 We should change this test file to test_normalizer.py instead to reflect that

(And I will have to update the example query to reflect that as well)

)


SAMPLES = Path(__file__).resolve().parents[3] / "data-explorations" / "google-places" / "samples"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

📝 Changes requested: Place test data inside this folder.

The data-explorations folder should be largely isolated, and expected to be removed soon.

Comment on lines +6 to +9
from pipelines.google_places.normalizer import (
GooglePlacesDonationNormalizer,
GooglePlacesRepairNormalizer,
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You'll need to update this statement to reflect the changes above

Comment on lines +27 to +51
def normalize_google_place(
raw: dict[str, Any],
*,
data_source: str,
data_source_id: str,
item_category: ItemCategory,
activity: Activity,
) -> dict[str, Any]:
display_name = raw.get("displayName") or {}
formatted_address = raw.get("formattedAddress")
location = raw.get("location") or {}
return {
"data_source_id": data_source_id,
"data_source": data_source,
"name": display_name.get("text") or data_source_id,
"lat": location.get("latitude", 0.0),
"lon": location.get("longitude", 0.0),
"address": Address(
street=formatted_address,
postcode=extract_postcode(formatted_address),
),
"contact": Contact(),
"services": [Service(activity=activity, item_category=item_category)],
"availability": Availability(),
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

📝 This method should be the GooglePlacesNormalizer class.
I would also move extract_postcode to that class as well

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.

3 participants