From 0ea4aa71e0aafda0d10c39609b1e15fdb4ed027d Mon Sep 17 00:00:00 2001 From: Jesus Leal Trujillo Date: Thu, 10 Sep 2026 14:38:47 -0400 Subject: [PATCH 1/3] feat: add price_feed_v2 and price_feed_smoothed services Wraps POST /v2/price_feed/price_feed and POST /v2/price_feed/price_feed_smoothed as client.price_feed_v2.price_feed and client.price_feed_v2.price_feed_smoothed, reusing PropertyTypeService so property_type (ALL, SINGLE_FAMILY, NEW_CONSTRUCTION) is sent in the POST body. ParclLabsService.url is now optional for POST-only endpoints. Bumps version to 1.19.0. --- CHANGELOG.md | 4 + README.md | 17 ++++ parcllabs/__version__.py | 2 +- parcllabs/parcllabs_client.py | 18 +++- parcllabs/services/parcllabs_service.py | 6 +- tests/integration/test_api.py | 54 ++++++++++++ tests/test_price_feed_v2.py | 112 ++++++++++++++++++++++++ 7 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 tests/test_price_feed_v2.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e7430a..1b50515 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +### v1.19.0 +- Added `client.price_feed_v2.price_feed.retrieve` and `client.price_feed_v2.price_feed_smoothed.retrieve`, wrapping the new `POST /v2/price_feed/price_feed` and `POST /v2/price_feed/price_feed_smoothed` endpoints. Both accept `parcl_ids`, `start_date`, `end_date`, `limit`, `auto_paginate`, and a `property_type` filter (`ALL`, `SINGLE_FAMILY`, or `NEW_CONSTRUCTION`; defaults to `ALL`). The smoothed series is the median of the last 30 daily prints per market. +- `ParclLabsService` now supports POST-only endpoints: `url` is optional when `post_url` is provided. + ### v1.18.0 - **`property_v2.search.retrieve`: `limit` is now a cap on the total number of properties returned, not a page size.** Pagination is handled internally to satisfy it. Previously, passing *any* explicit `limit` silently disabled auto-pagination, so `limit=1000` returned one page of 1,000 and discarded every remaining match with no error or warning. Calls with `limit <= 50000` are unaffected — same request, same results. - **`limit` above 50,000 now paginates instead of failing.** Previously the request was rejected by the API with `422 limit input should be less than or equal to 50000`. diff --git a/README.md b/README.md index 897a587..bacce62 100644 --- a/README.md +++ b/README.md @@ -348,6 +348,12 @@ Gets the daily price feed for a specified `parcl_id`. ##### Rental Price Feed Gets the daily updated Parcl Labs Rental Price Feed for a given `parcl_id`. +##### Price Feed V2 +Gets the daily price feed for the given `parcl_ids`, filtered by `property_type`: `ALL` (default), `SINGLE_FAMILY`, or `NEW_CONSTRUCTION`. + +##### Price Feed V2 Smoothed +Gets the smoothed price feed, the median of the last 30 daily prints for each market, which removes day-to-day noise. Covers the 112 core Parcl Labs markets from 2011-01-30 and accepts the same `property_type` filter. + ```python # get 2 price feeds trading on the Parcl Exchange pricefeed_markets = client.search.markets.retrieve( @@ -370,6 +376,17 @@ rental_price_feeds = client.price_feed.rental_price_feed.retrieve( start_date=start_date, end_date=end_date ) +price_feeds_v2 = client.price_feed_v2.price_feed.retrieve( + parcl_ids=pricefeed_ids, + start_date=start_date, + end_date=end_date, + property_type='SINGLE_FAMILY' +) +price_feeds_v2_smoothed = client.price_feed_v2.price_feed_smoothed.retrieve( + parcl_ids=pricefeed_ids, + start_date=start_date, + end_date=end_date +) ``` ### Property diff --git a/parcllabs/__version__.py b/parcllabs/__version__.py index 4c0681d..ccdbf7c 100644 --- a/parcllabs/__version__.py +++ b/parcllabs/__version__.py @@ -1 +1 @@ -VERSION = "1.18.0" +VERSION = "1.19.0" diff --git a/parcllabs/parcllabs_client.py b/parcllabs/parcllabs_client.py index 55ae83d..fef24a0 100644 --- a/parcllabs/parcllabs_client.py +++ b/parcllabs/parcllabs_client.py @@ -20,8 +20,8 @@ def __init__(self, client: object) -> None: def add_service( self, name: str, - url: str, service_class: ParclLabsService, + url: str | None = None, post_url: str | None = None, alias: str | None = None, ) -> None: @@ -60,6 +60,7 @@ def __init__( def _initialize_services(self) -> None: self.price_feed = self._create_price_feed_services() + self.price_feed_v2 = self._create_price_feed_v2_services() self.investor_metrics = self._create_investor_metrics_services() self.market_metrics = self._create_market_metrics_services() self.new_construction_metrics = self._create_new_construction_metrics_services() @@ -97,6 +98,21 @@ def _create_price_feed_services(self) -> ServiceGroup: self._add_services_to_group(group, services) return group + def _create_price_feed_v2_services(self) -> ServiceGroup: + group = self._create_service_group() + services = { + "price_feed": { + "post_url": "/v2/price_feed/price_feed", + "service_class": PropertyTypeService, + }, + "price_feed_smoothed": { + "post_url": "/v2/price_feed/price_feed_smoothed", + "service_class": PropertyTypeService, + }, + } + self._add_services_to_group(group, services) + return group + def _create_investor_metrics_services(self) -> ServiceGroup: group = self._create_service_group() services = { diff --git a/parcllabs/services/parcllabs_service.py b/parcllabs/services/parcllabs_service.py index 9138b67..74de795 100644 --- a/parcllabs/services/parcllabs_service.py +++ b/parcllabs/services/parcllabs_service.py @@ -21,14 +21,16 @@ class ParclLabsService: Base class for working with data from the Parcl Labs API. """ - def __init__(self, url: str, client: object, post_url: str | None = None) -> None: + def __init__(self, url: str | None, client: object, post_url: str | None = None) -> None: self.url = url self.post_url = post_url self.client = client if client is None: raise ValueError("Missing required client object.") + if url is None and post_url is None: + raise ValueError("At least one of url or post_url must be provided.") self.api_url = client.api_url - self.full_url = self.api_url + self.url + self.full_url = self.api_url + self.url if url else None self.full_post_url = self.api_url + self.post_url if post_url else None self.api_key = client.api_key self.headers = self._get_headers() diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py index 27df589..cf91a16 100644 --- a/tests/integration/test_api.py +++ b/tests/integration/test_api.py @@ -155,3 +155,57 @@ def test_multiple_post_requests_with_bad_parcl_ids(client: ParclLabsClient) -> N assert results.shape[0] == len(TEST_PIDS) * 12 assert results.groupby("parcl_id").size().unique() == 12 + + +def test_price_feed_v2_post_request(client: ParclLabsClient) -> None: + test_pids = PRICEFEED_MARKETS[:3] + start_date = "2024-01-01" + end_date = "2024-01-31" + days = (pd.to_datetime(end_date) - pd.to_datetime(start_date)).days + 1 + + results = client.price_feed_v2.price_feed.retrieve( + parcl_ids=test_pids, + start_date=start_date, + end_date=end_date, + auto_paginate=True, + ) + + assert set(results["parcl_id"].unique()) == set(test_pids) + assert results.shape[0] == len(test_pids) * days + assert results["date"].min().date() == pd.to_datetime(start_date).date() + assert results["date"].max().date() == pd.to_datetime(end_date).date() + + +def test_price_feed_v2_property_type(client: ParclLabsClient) -> None: + test_pid = [5826765] # US parcl id + start_date = "2024-01-01" + end_date = "2024-01-31" + days = (pd.to_datetime(end_date) - pd.to_datetime(start_date)).days + 1 + + results = client.price_feed_v2.price_feed.retrieve( + parcl_ids=test_pid, + start_date=start_date, + end_date=end_date, + property_type="SINGLE_FAMILY", + ) + + assert results["parcl_id"].unique() == test_pid[0] + assert results.shape[0] == days + + +def test_price_feed_v2_smoothed_post_request(client: ParclLabsClient) -> None: + test_pid = [5826765] # US parcl id + start_date = "2024-01-01" + end_date = "2024-01-31" + days = (pd.to_datetime(end_date) - pd.to_datetime(start_date)).days + 1 + + results = client.price_feed_v2.price_feed_smoothed.retrieve( + parcl_ids=test_pid, + start_date=start_date, + end_date=end_date, + ) + + assert results["parcl_id"].unique() == test_pid[0] + assert results.shape[0] == days + assert results["date"].min().date() == pd.to_datetime(start_date).date() + assert results["date"].max().date() == pd.to_datetime(end_date).date() diff --git a/tests/test_price_feed_v2.py b/tests/test_price_feed_v2.py new file mode 100644 index 0000000..49f8d22 --- /dev/null +++ b/tests/test_price_feed_v2.py @@ -0,0 +1,112 @@ +from unittest.mock import Mock, patch + +import pandas as pd +import pytest + +from parcllabs.parcllabs_client import ParclLabsClient +from parcllabs.services.metrics.property_type_service import PropertyTypeService +from parcllabs.services.parcllabs_service import ParclLabsService + +API_URL = "https://api.example.com" +SERVICE_PATHS = { + "price_feed": "/v2/price_feed/price_feed", + "price_feed_smoothed": "/v2/price_feed/price_feed_smoothed", +} +ITEMS = [ + {"date": "2024-01-01", "price_feed": 250.5, "parcl_id": 2900187}, + {"date": "2024-01-02", "price_feed": 251.0, "parcl_id": 2900187}, +] + + +def _page(items: list[dict]) -> dict: + return { + "items": items, + "total": len(items), + "limit": 1000, + "offset": 0, + "links": {"first": None, "last": None, "self": None, "next": None, "prev": None}, + } + + +@pytest.fixture +def client() -> ParclLabsClient: + return ParclLabsClient(api_key="test_api_key", api_url=API_URL) + + +@pytest.fixture(params=list(SERVICE_PATHS)) +def service(request: pytest.FixtureRequest, client: ParclLabsClient) -> PropertyTypeService: + return getattr(client.price_feed_v2, request.param) + + +class TestPriceFeedV2Wiring: + def test_group_lists_both_services(self, client: ParclLabsClient) -> None: + assert client.price_feed_v2.services == list(SERVICE_PATHS) + + @pytest.mark.parametrize(("name", "path"), list(SERVICE_PATHS.items())) + def test_services_are_post_only(self, client: ParclLabsClient, name: str, path: str) -> None: + svc = getattr(client.price_feed_v2, name) + assert isinstance(svc, PropertyTypeService) + assert svc.full_post_url == API_URL + path + assert svc.url is None + assert svc.full_url is None + + +class TestPostOnlyServiceInit: + @pytest.fixture + def mock_client(self) -> Mock: + mock_client = Mock() + mock_client.api_url = API_URL + mock_client.api_key = "test_api_key" + return mock_client + + def test_post_url_only(self, mock_client: Mock) -> None: + svc = ParclLabsService(url=None, client=mock_client, post_url="/v2/x") + assert svc.full_url is None + assert svc.full_post_url == API_URL + "/v2/x" + + def test_requires_url_or_post_url(self, mock_client: Mock) -> None: + with pytest.raises(ValueError, match="url or post_url"): + ParclLabsService(url=None, client=mock_client) + + +class TestPriceFeedV2Retrieve: + def test_property_type_sent_in_post_body(self, service: PropertyTypeService) -> None: + with patch.object(service, "_fetch_post", return_value=_page(ITEMS)) as mock_post: + service.retrieve( + parcl_ids=[2900187, 2900078], + start_date="2024-01-01", + end_date="2024-01-31", + property_type="single_family", + ) + + params, data, auto_paginate = mock_post.call_args.args + assert data["parcl_id"] == ["2900187", "2900078"] + assert data["start_date"] == "2024-01-01" + assert data["end_date"] == "2024-01-31" + assert data["property_type"] == "SINGLE_FAMILY" + assert "property_type" not in params + assert auto_paginate is False + + def test_property_type_omitted_by_default(self, service: PropertyTypeService) -> None: + with patch.object(service, "_fetch_post", return_value=_page(ITEMS)) as mock_post: + service.retrieve(parcl_ids=[2900187]) + + _, data, _ = mock_post.call_args.args + assert "property_type" not in data + + def test_limit_sent_as_query_param(self, service: PropertyTypeService) -> None: + with patch.object(service, "_fetch_post", return_value=_page(ITEMS)) as mock_post: + service.retrieve(parcl_ids=[2900187], limit=500, auto_paginate=True) + + params, _, auto_paginate = mock_post.call_args.args + assert params == {"limit": 500} + assert auto_paginate is True + + def test_returns_dataframe(self, service: PropertyTypeService) -> None: + with patch.object(service, "_fetch_post", return_value=_page(ITEMS)): + results = service.retrieve(parcl_ids=[2900187]) + + assert isinstance(results, pd.DataFrame) + assert len(results) == 2 + assert {"date", "price_feed", "parcl_id"} <= set(results.columns) + assert results["parcl_id"].tolist() == [2900187, 2900187] From 697dfc39eb202ff9f84cb4d04c1720937582d9be Mon Sep 17 00:00:00 2001 From: Jesus Leal Trujillo Date: Thu, 10 Sep 2026 14:47:58 -0400 Subject: [PATCH 2/3] fix: address price_feed_v2 review findings - POST requests send limit/offset as query params only; offset passed via params was previously misrouted into the JSON body and limit duplicated there - auto-pagination no longer re-applies the caller's initial offset on next pages - restore ServiceGroup.add_service positional order (url stays nullable and is passed explicitly as None for the POST-only v2 services) - PropertyTypeService / PortfolioSizeService copy the caller's params instead of mutating them - tests: positional registration, offset routing, param immutability, two-page pagination through requests.request, first-page-only default; live forced pagination (limit=2) and all three property_type values on both v2 routes - docs: page-size / auto_paginate behavior and API-side property_type validation --- CHANGELOG.md | 4 +- README.md | 2 + parcllabs/common.py | 5 + parcllabs/parcllabs_client.py | 4 +- .../metrics/portfolio_size_service.py | 3 +- .../services/metrics/property_type_service.py | 3 +- parcllabs/services/parcllabs_service.py | 29 ++- tests/integration/test_api.py | 27 ++- tests/test_price_feed_v2.py | 178 +++++++++++++++--- 9 files changed, 205 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b50515..23ff402 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ### v1.19.0 - Added `client.price_feed_v2.price_feed.retrieve` and `client.price_feed_v2.price_feed_smoothed.retrieve`, wrapping the new `POST /v2/price_feed/price_feed` and `POST /v2/price_feed/price_feed_smoothed` endpoints. Both accept `parcl_ids`, `start_date`, `end_date`, `limit`, `auto_paginate`, and a `property_type` filter (`ALL`, `SINGLE_FAMILY`, or `NEW_CONSTRUCTION`; defaults to `ALL`). The smoothed series is the median of the last 30 daily prints per market. -- `ParclLabsService` now supports POST-only endpoints: `url` is optional when `post_url` is provided. +- `ParclLabsService` accepts `url=None` for POST-only endpoints when `post_url` is provided. +- POST requests now send `limit` and `offset` as query parameters only. Previously an `offset` passed through `params` was misrouted into the JSON body, and `limit` was duplicated there. Auto-pagination no longer re-applies a caller-supplied `offset` to subsequent pages. +- `PropertyTypeService` and `PortfolioSizeService` no longer mutate the caller's `params` dictionary. ### v1.18.0 - **`property_v2.search.retrieve`: `limit` is now a cap on the total number of properties returned, not a page size.** Pagination is handled internally to satisfy it. Previously, passing *any* explicit `limit` silently disabled auto-pagination, so `limit=1000` returned one page of 1,000 and discarded every remaining match with no error or warning. Calls with `limit <= 50000` are unaffected — same request, same results. diff --git a/README.md b/README.md index bacce62..6b8efc8 100644 --- a/README.md +++ b/README.md @@ -354,6 +354,8 @@ Gets the daily price feed for the given `parcl_ids`, filtered by `property_type` ##### Price Feed V2 Smoothed Gets the smoothed price feed, the median of the last 30 daily prints for each market, which removes day-to-day noise. Covers the 112 core Parcl Labs markets from 2011-01-30 and accepts the same `property_type` filter. +For both v2 endpoints, `limit` is the page size (up to 10,000 rows per request) and a call returns a single page unless `auto_paginate=True` is passed, which follows the pagination links until the full date range is returned. Invalid `property_type` values are rejected by the API. + ```python # get 2 price feeds trading on the Parcl Exchange pricefeed_markets = client.search.markets.retrieve( diff --git a/parcllabs/common.py b/parcllabs/common.py index 29a580d..1d0cb5d 100644 --- a/parcllabs/common.py +++ b/parcllabs/common.py @@ -10,6 +10,11 @@ ID_COLUMNS = [ResponseColumns.PARCL_ID.value, ResponseColumns.PARCL_PROPERTY_ID.value] DATE_COLUMNS = [ResponseColumns.DATE.value, ResponseColumns.EVENT_DATE.value] +POST_QUERY_PARAMS = [ + ResponseColumns.LIMIT.value, + ResponseColumns.OFFSET.value, +] + DELETE_FROM_OUTPUT = [ ResponseColumns.TOTAL.value, ResponseColumns.LIMIT.value, diff --git a/parcllabs/parcllabs_client.py b/parcllabs/parcllabs_client.py index fef24a0..544de83 100644 --- a/parcllabs/parcllabs_client.py +++ b/parcllabs/parcllabs_client.py @@ -20,8 +20,8 @@ def __init__(self, client: object) -> None: def add_service( self, name: str, + url: str | None, service_class: ParclLabsService, - url: str | None = None, post_url: str | None = None, alias: str | None = None, ) -> None: @@ -102,10 +102,12 @@ def _create_price_feed_v2_services(self) -> ServiceGroup: group = self._create_service_group() services = { "price_feed": { + "url": None, # POST-only endpoint "post_url": "/v2/price_feed/price_feed", "service_class": PropertyTypeService, }, "price_feed_smoothed": { + "url": None, # POST-only endpoint "post_url": "/v2/price_feed/price_feed_smoothed", "service_class": PropertyTypeService, }, diff --git a/parcllabs/services/metrics/portfolio_size_service.py b/parcllabs/services/metrics/portfolio_size_service.py index 1d1ec2a..fe9eaf4 100644 --- a/parcllabs/services/metrics/portfolio_size_service.py +++ b/parcllabs/services/metrics/portfolio_size_service.py @@ -19,8 +19,7 @@ def retrieve( """ Retrieve portfolio size metrics for given parameters. """ - if params is None: - params = {} + params = dict(params or {}) if portfolio_size: params["portfolio_size"] = portfolio_size.upper() diff --git a/parcllabs/services/metrics/property_type_service.py b/parcllabs/services/metrics/property_type_service.py index aa4a194..b081cbc 100644 --- a/parcllabs/services/metrics/property_type_service.py +++ b/parcllabs/services/metrics/property_type_service.py @@ -19,8 +19,7 @@ def retrieve( """ Retrieve property type metrics for given parameters. """ - if params is None: - params = {} + params = dict(params or {}) if property_type: params["property_type"] = property_type.upper() diff --git a/parcllabs/services/parcllabs_service.py b/parcllabs/services/parcllabs_service.py index 74de795..bc649e9 100644 --- a/parcllabs/services/parcllabs_service.py +++ b/parcllabs/services/parcllabs_service.py @@ -9,7 +9,12 @@ from requests.exceptions import RequestException from parcllabs.__version__ import VERSION -from parcllabs.common import DELETE_FROM_OUTPUT, GET_METHOD, POST_METHOD +from parcllabs.common import ( + DELETE_FROM_OUTPUT, + GET_METHOD, + POST_METHOD, + POST_QUERY_PARAMS, +) from parcllabs.enums import RequestLimits, RequestMethods, ResponseCodes from parcllabs.exceptions import NotFoundError from parcllabs.services.data_utils import safe_concat_and_format_dtypes @@ -174,15 +179,20 @@ def _fetch( params["limit"] = self.client.limit if self.full_post_url: - # convert the list of parcl_ids into post body params, formatted - # as strings if params.get("limit"): params["limit"] = self._validate_limit(POST_METHOD, params["limit"]) - data = {"parcl_id": [str(pid) for pid in parcl_ids], **params} - params = {"limit": params["limit"]} if params.get("limit") else {} + # limit/offset travel in the query string; everything else, plus the + # parcl_ids formatted as strings, goes in the JSON body + query_params = { + k: v for k, v in params.items() if k in POST_QUERY_PARAMS and v is not None + } + data = { + "parcl_id": [str(pid) for pid in parcl_ids], + **{k: v for k, v in params.items() if k not in POST_QUERY_PARAMS}, + } - return self._fetch_post(params, data, auto_paginate) + return self._fetch_post(query_params, data, auto_paginate) if params.get("limit"): params["limit"] = self._validate_limit(GET_METHOD, params["limit"]) @@ -261,12 +271,15 @@ def _process_and_paginate_response( if auto_paginate and "links" in result and result["links"].get("next") is not None: all_items = result["items"] + # each next link already carries its own offset; re-sending the + # caller's initial offset would override it and repeat pages + next_params = {k: v for k, v in original_params.items() if k != "offset"} while result["links"].get("next") is not None: next_url = result["links"]["next"] if referring_method == "post": - next_response = self._post(next_url, data=data, params=original_params) + next_response = self._post(next_url, data=data, params=next_params) else: - next_response = self._get(next_url, params=original_params) + next_response = self._get(next_url, params=next_params) next_response.raise_for_status() result = next_response.json() all_items.extend(result["items"]) diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py index cf91a16..3687c07 100644 --- a/tests/integration/test_api.py +++ b/tests/integration/test_api.py @@ -176,36 +176,43 @@ def test_price_feed_v2_post_request(client: ParclLabsClient) -> None: assert results["date"].max().date() == pd.to_datetime(end_date).date() -def test_price_feed_v2_property_type(client: ParclLabsClient) -> None: +@pytest.mark.parametrize("service_name", ["price_feed", "price_feed_smoothed"]) +def test_price_feed_v2_forced_pagination(client: ParclLabsClient, service_name: str) -> None: test_pid = [5826765] # US parcl id start_date = "2024-01-01" - end_date = "2024-01-31" + end_date = "2024-01-05" days = (pd.to_datetime(end_date) - pd.to_datetime(start_date)).days + 1 - results = client.price_feed_v2.price_feed.retrieve( + results = getattr(client.price_feed_v2, service_name).retrieve( parcl_ids=test_pid, start_date=start_date, end_date=end_date, - property_type="SINGLE_FAMILY", + limit=2, # forces 3 pages for a 5 day window + auto_paginate=True, ) - assert results["parcl_id"].unique() == test_pid[0] assert results.shape[0] == days + assert results["date"].is_unique + assert results["date"].min().date() == pd.to_datetime(start_date).date() + assert results["date"].max().date() == pd.to_datetime(end_date).date() -def test_price_feed_v2_smoothed_post_request(client: ParclLabsClient) -> None: +@pytest.mark.parametrize("service_name", ["price_feed", "price_feed_smoothed"]) +@pytest.mark.parametrize("property_type", ["ALL", "SINGLE_FAMILY", "NEW_CONSTRUCTION"]) +def test_price_feed_v2_property_types( + client: ParclLabsClient, service_name: str, property_type: str +) -> None: test_pid = [5826765] # US parcl id start_date = "2024-01-01" - end_date = "2024-01-31" + end_date = "2024-01-05" days = (pd.to_datetime(end_date) - pd.to_datetime(start_date)).days + 1 - results = client.price_feed_v2.price_feed_smoothed.retrieve( + results = getattr(client.price_feed_v2, service_name).retrieve( parcl_ids=test_pid, start_date=start_date, end_date=end_date, + property_type=property_type, ) assert results["parcl_id"].unique() == test_pid[0] assert results.shape[0] == days - assert results["date"].min().date() == pd.to_datetime(start_date).date() - assert results["date"].max().date() == pd.to_datetime(end_date).date() diff --git a/tests/test_price_feed_v2.py b/tests/test_price_feed_v2.py index 49f8d22..e36260b 100644 --- a/tests/test_price_feed_v2.py +++ b/tests/test_price_feed_v2.py @@ -3,7 +3,7 @@ import pandas as pd import pytest -from parcllabs.parcllabs_client import ParclLabsClient +from parcllabs.parcllabs_client import ParclLabsClient, ServiceGroup from parcllabs.services.metrics.property_type_service import PropertyTypeService from parcllabs.services.parcllabs_service import ParclLabsService @@ -12,22 +12,41 @@ "price_feed": "/v2/price_feed/price_feed", "price_feed_smoothed": "/v2/price_feed/price_feed_smoothed", } -ITEMS = [ - {"date": "2024-01-01", "price_feed": 250.5, "parcl_id": 2900187}, - {"date": "2024-01-02", "price_feed": 251.0, "parcl_id": 2900187}, +PARCL_ID = 2900187 +PAGE_ONE = [ + {"date": "2024-01-01", "price_feed": 250.5, "parcl_id": PARCL_ID}, + {"date": "2024-01-02", "price_feed": 251.0, "parcl_id": PARCL_ID}, ] +PAGE_TWO = [ + {"date": "2024-01-03", "price_feed": 251.5, "parcl_id": PARCL_ID}, + {"date": "2024-01-04", "price_feed": 252.0, "parcl_id": PARCL_ID}, +] +REQUEST_TARGET = "parcllabs.services.parcllabs_service.requests.request" -def _page(items: list[dict]) -> dict: +def _page(items: list[dict], next_link: str | None = None) -> dict: return { - "items": items, + "items": [dict(item) for item in items], # the service extends items in place "total": len(items), - "limit": 1000, + "limit": 2, "offset": 0, - "links": {"first": None, "last": None, "self": None, "next": None, "prev": None}, + "links": { + "first": None, + "last": None, + "self": None, + "next": next_link, + "prev": None, + }, } +def _response(items: list[dict], next_link: str | None = None) -> Mock: + response = Mock() + response.status_code = 200 + response.json.return_value = _page(items, next_link) + return response + + @pytest.fixture def client() -> ParclLabsClient: return ParclLabsClient(api_key="test_api_key", api_url=API_URL) @@ -51,6 +70,29 @@ def test_services_are_post_only(self, client: ParclLabsClient, name: str, path: assert svc.full_url is None +class TestServiceGroupCompatibility: + def test_positional_registration_still_works(self, client: ParclLabsClient) -> None: + group = ServiceGroup(client) + group.add_service("custom", "/custom/{parcl_id}", ParclLabsService) + + assert group.custom.full_url == API_URL + "/custom/{parcl_id}" + assert group.custom.full_post_url is None + assert group.services == ["custom"] + + def test_keyword_registration_with_alias(self, client: ParclLabsClient) -> None: + group = ServiceGroup(client) + group.add_service( + name="custom", + url="/custom/{parcl_id}", + service_class=ParclLabsService, + post_url="/custom", + alias="custom_alias", + ) + + assert group.custom is group.custom_alias + assert group.custom.full_post_url == API_URL + "/custom" + + class TestPostOnlyServiceInit: @pytest.fixture def mock_client(self) -> Mock: @@ -71,42 +113,126 @@ def test_requires_url_or_post_url(self, mock_client: Mock) -> None: class TestPriceFeedV2Retrieve: def test_property_type_sent_in_post_body(self, service: PropertyTypeService) -> None: - with patch.object(service, "_fetch_post", return_value=_page(ITEMS)) as mock_post: + with patch.object(service, "_fetch_post", return_value=_page(PAGE_ONE)) as mock_post: service.retrieve( - parcl_ids=[2900187, 2900078], + parcl_ids=[PARCL_ID, 2900078], start_date="2024-01-01", end_date="2024-01-31", property_type="single_family", ) params, data, auto_paginate = mock_post.call_args.args - assert data["parcl_id"] == ["2900187", "2900078"] - assert data["start_date"] == "2024-01-01" - assert data["end_date"] == "2024-01-31" - assert data["property_type"] == "SINGLE_FAMILY" - assert "property_type" not in params + assert data == { + "parcl_id": [str(PARCL_ID), "2900078"], + "start_date": "2024-01-01", + "end_date": "2024-01-31", + "property_type": "SINGLE_FAMILY", + } + assert params == {} assert auto_paginate is False def test_property_type_omitted_by_default(self, service: PropertyTypeService) -> None: - with patch.object(service, "_fetch_post", return_value=_page(ITEMS)) as mock_post: - service.retrieve(parcl_ids=[2900187]) + with patch.object(service, "_fetch_post", return_value=_page(PAGE_ONE)) as mock_post: + service.retrieve(parcl_ids=[PARCL_ID]) _, data, _ = mock_post.call_args.args assert "property_type" not in data - def test_limit_sent_as_query_param(self, service: PropertyTypeService) -> None: - with patch.object(service, "_fetch_post", return_value=_page(ITEMS)) as mock_post: - service.retrieve(parcl_ids=[2900187], limit=500, auto_paginate=True) + def test_limit_and_offset_are_query_params(self, service: PropertyTypeService) -> None: + with patch.object(service, "_fetch_post", return_value=_page(PAGE_ONE)) as mock_post: + service.retrieve(parcl_ids=[PARCL_ID], limit=2, params={"offset": 2}) + + params, data, _ = mock_post.call_args.args + assert params == {"limit": 2, "offset": 2} + assert "limit" not in data + assert "offset" not in data + + def test_does_not_mutate_caller_params(self, client: ParclLabsClient) -> None: + shared_params: dict = {} + daily = client.price_feed_v2.price_feed + smoothed = client.price_feed_v2.price_feed_smoothed + + with patch.object(daily, "_fetch_post", return_value=_page(PAGE_ONE)): + daily.retrieve( + parcl_ids=[PARCL_ID], property_type="SINGLE_FAMILY", params=shared_params + ) + assert shared_params == {} - params, _, auto_paginate = mock_post.call_args.args - assert params == {"limit": 500} - assert auto_paginate is True + with patch.object(smoothed, "_fetch_post", return_value=_page(PAGE_ONE)) as mock_post: + smoothed.retrieve(parcl_ids=[PARCL_ID], params=shared_params) + _, data, _ = mock_post.call_args.args + assert "property_type" not in data def test_returns_dataframe(self, service: PropertyTypeService) -> None: - with patch.object(service, "_fetch_post", return_value=_page(ITEMS)): - results = service.retrieve(parcl_ids=[2900187]) + with patch.object(service, "_fetch_post", return_value=_page(PAGE_ONE)): + results = service.retrieve(parcl_ids=[PARCL_ID]) assert isinstance(results, pd.DataFrame) assert len(results) == 2 assert {"date", "price_feed", "parcl_id"} <= set(results.columns) - assert results["parcl_id"].tolist() == [2900187, 2900187] + assert results["parcl_id"].tolist() == [PARCL_ID, PARCL_ID] + + +class TestPriceFeedV2Pagination: + def test_auto_paginate_follows_next_links(self, service: PropertyTypeService) -> None: + url = API_URL + service.post_url + next_link = f"{url}?limit=2&offset=2" + expected_body = { + "parcl_id": [str(PARCL_ID)], + "start_date": "2024-01-01", + "end_date": "2024-01-04", + "property_type": "SINGLE_FAMILY", + } + + with patch( + REQUEST_TARGET, + side_effect=[_response(PAGE_ONE, next_link), _response(PAGE_TWO)], + ) as mock_request: + results = service.retrieve( + parcl_ids=[PARCL_ID], + start_date="2024-01-01", + end_date="2024-01-04", + property_type="SINGLE_FAMILY", + limit=2, + auto_paginate=True, + ) + + assert mock_request.call_count == 2 + first, second = mock_request.call_args_list + assert first.args == ("POST", url) + assert first.kwargs["params"] == {"limit": 2} + assert first.kwargs["json"] == expected_body + assert second.args == ("POST", next_link) + assert second.kwargs["params"] == {"limit": 2} + assert second.kwargs["json"] == expected_body + assert len(results) == 4 + assert results["date"].is_unique + + def test_auto_paginate_does_not_reapply_initial_offset( + self, service: PropertyTypeService + ) -> None: + next_link = f"{API_URL}{service.post_url}?limit=2&offset=4" + + with patch( + REQUEST_TARGET, + side_effect=[_response(PAGE_ONE, next_link), _response(PAGE_TWO)], + ) as mock_request: + service.retrieve( + parcl_ids=[PARCL_ID], limit=2, params={"offset": 2}, auto_paginate=True + ) + + first, second = mock_request.call_args_list + assert first.kwargs["params"] == {"limit": 2, "offset": 2} + assert second.args[1] == next_link + assert second.kwargs["params"] == {"limit": 2} + + def test_without_auto_paginate_returns_first_page_only( + self, service: PropertyTypeService + ) -> None: + next_link = f"{API_URL}{service.post_url}?limit=2&offset=2" + + with patch(REQUEST_TARGET, return_value=_response(PAGE_ONE, next_link)) as mock_request: + results = service.retrieve(parcl_ids=[PARCL_ID], limit=2) + + assert mock_request.call_count == 1 + assert len(results) == 2 From ad51a00a859a4a0c5c323547579f6999ba0ef2e3 Mon Sep 17 00:00:00 2001 From: Jesus Leal Trujillo Date: Thu, 10 Sep 2026 18:09:52 -0400 Subject: [PATCH 3/3] docs: use property_type='ALL' in README price_feed_v2 example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6b8efc8..9af370f 100644 --- a/README.md +++ b/README.md @@ -382,7 +382,7 @@ price_feeds_v2 = client.price_feed_v2.price_feed.retrieve( parcl_ids=pricefeed_ids, start_date=start_date, end_date=end_date, - property_type='SINGLE_FAMILY' + property_type='ALL' ) price_feeds_v2_smoothed = client.price_feed_v2.price_feed_smoothed.retrieve( parcl_ids=pricefeed_ids,