Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Property search and event endpoints #53

Merged
merged 10 commits into from
Jul 3, 2024
Merged
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
153 changes: 94 additions & 59 deletions Pipfile.lock

Large diffs are not rendered by default.

33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,39 @@ results = client.portfolio_metrics.sf_new_listings_for_rent_rolling_counts.retri
)
```

#### Property

##### Property Search Markets
Gets a list of unique identifiers (parcl_property_id) for units that correspond to specific markets or parameters defined by the user. The parcl_property_id is key to navigating the Parcl Labs API, serving as the core mechanism for retrieving unit-level information.
```python
# get all condos over 3000 sq ft in the 10001 zip code area
units = client.property.search.retrieve(
zip=10001,
sq_ft_min=3000,
property_type='condo',
)
# to use these ids in event history
parcl_property_id_list = units['parcl_property_id'].tolist()
```

##### Property Event History
Gets unit-level properties and their housing event history, including sales, listings, and rentals. The response includes detailed property information and historical event data for each specified property.
```python
sale_events = client.property.events.retrieve(
parcl_property_ids=parcl_property_id_list[0:10],
event_type='SALE',
start_date='2020-01-01',
end_date='2024-06-30'
)

rental_events = client.property.events.retrieve(
parcl_property_ids=parcl_property_id_list[0:10],
event_type='RENTAL',
start_date='2020-01-01',
end_date='2024-06-30'
)
```

##### Utility Functions
Want to keep track of the estimated number of credits you are using in a given session?

Expand Down
2 changes: 1 addition & 1 deletion parcllabs/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
VERSION = "1.1.1"
VERSION = "1.2.0"
2 changes: 2 additions & 0 deletions parcllabs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,5 @@
]

VALID_SORT_ORDER = ["ASC", "DESC"]

VALID_EVENT_TYPES = ["SALE", "LISTING", "RENTAL", "ALL"]
10 changes: 10 additions & 0 deletions parcllabs/parcllabs_client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from parcllabs import api_base
from parcllabs.services.parcllabs_service import ParclLabsService
from parcllabs.services.portfolio_size_service import PortfolioSizeService
from parcllabs.services.property_events_service import PropertyEventsService
from parcllabs.services.property_search import PropertySearch
from parcllabs.services.property_type_service import PropertyTypeService
from parcllabs.services.search import SearchMarkets

Expand Down Expand Up @@ -185,3 +187,11 @@ def __init__(self, api_key: str, limit: int = 12):

self.search = ServiceGroup(self, limit)
self.search.add_service("markets", "/v1/search/markets", SearchMarkets)

self.property = ServiceGroup(self, limit)
self.property.add_service(
"search", "/v1/property/search_markets", PropertySearch
)
self.property.add_service(
"events", "/v1/property/event_history", PropertyEventsService
)
70 changes: 52 additions & 18 deletions parcllabs/services/parcllabs_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,14 +177,40 @@ def _sync_request(
url: str = None,
params: Optional[Mapping[str, Any]] = None,
is_next: bool = False,
method: str = "GET",
) -> Any:
if url:
url = url
elif parcl_id:
url = self.url.format(parcl_id=parcl_id)
else:
url = self.url
return self.get(url=url, params=params, is_next=is_next)
if method == "GET":
return self.get(url=url, params=params, is_next=is_next)
elif method == "POST":
return self.post(url=url, params=params)
else:
raise ValueError(f"Unsupported HTTP method: {method}")

def error_handling(self, response: requests.Response) -> None:
try:
error = ""
error_details = response.json()
error_message = error_details.get("detail", "No detail provided by API")
error = error_message
if response.status_code == 403:
error = f"{error_message}. Visit https://dashboard.parcllabs.com for more information or reach out to [email protected]."
if response.status_code == 429:
error = error_details.get("error", "Rate Limit Exceeded")
except json.JSONDecodeError:
error_message = "Failed to decode JSON error response"
type_of_error = ""
if 400 <= response.status_code < 500:
type_of_error = "Client"
elif 500 <= response.status_code < 600:
type_of_error = "Server"
msg = f"{response.status_code} {type_of_error} Error: {error}"
raise RequestException(msg)

def get(self, url: str, params: dict = None, is_next: bool = False):
"""
Expand All @@ -211,23 +237,31 @@ def get(self, url: str, params: dict = None, is_next: bool = False):
response.raise_for_status()
return response.json()
except requests.exceptions.HTTPError:
try:
error_details = response.json()
error_message = error_details.get("detail", "No detail provided by API")
error = error_message
if response.status_code == 403:
error = f"{error_message}. Visit https://dashboard.parcllabs.com for more information or reach out to [email protected]."
if response.status_code == 429:
error = error_details.get("error", "Rate Limit Exceeded")
except json.JSONDecodeError:
error_message = "Failed to decode JSON error response"
type_of_error = ""
if 400 <= response.status_code < 500:
type_of_error = "Client"
elif 500 <= response.status_code < 600:
type_of_error = "Server"
msg = f"{response.status_code} {type_of_error} Error: {error}"
raise RequestException(msg)
self.error_handling(response)
except requests.exceptions.RequestException as err:
raise RequestException(f"Request failed: {str(err)}")
except Exception as e:
raise RequestException(f"An unexpected error occurred: {str(e)}")

def post(self, url: str, params: dict = None):
"""
Send a GET request to the specified URL with the given parameters.

Args:
url (str): The URL endpoint to request.
params (dict, optional): The parameters to send in the query string.

Returns:
dict: The JSON response as a dictionary.
"""
try:
full_url = self.api_url + url
headers = self._get_headers()
response = requests.post(full_url, headers=headers, json=params)
response.raise_for_status()
return response.json()
except requests.exceptions.HTTPError:
self.error_handling(response)
except requests.exceptions.RequestException as err:
raise RequestException(f"Request failed: {str(err)}")
except Exception as e:
Expand Down
74 changes: 74 additions & 0 deletions parcllabs/services/property_events_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import pandas as pd
from alive_progress import alive_bar
from typing import Any, Mapping, Optional, List
from parcllabs.common import (
DEFAULT_LIMIT,
VALID_EVENT_TYPES,
)
from parcllabs.services.data_utils import safe_concat_and_format_dtypes
from parcllabs.services.parcllabs_service import ParclLabsService


CHUNK_SIZE = 1000


class PropertyEventsService(ParclLabsService):
"""
Retrieve parcl_property_id for geographic markets in the Parcl Labs API.
"""

def __init__(self, limit: int = DEFAULT_LIMIT, *args, **kwargs):
super().__init__(limit=limit, *args, **kwargs)

def _as_pd_dataframe(self, data: List[Mapping[str, Any]]) -> Any:
data_container = []
for results in data:
meta_fields = [["property", key] for key in results["property"].keys()]
df = pd.json_normalize(results, "events", meta=meta_fields)
updated_cols_names = [
c.replace("property.", "") for c in df.columns.tolist()
] # for nested json
df.columns = updated_cols_names
data_container.append(df)
output = safe_concat_and_format_dtypes(data_container)
return output

def retrieve(
self,
parcl_property_ids: List[int],
event_type: str = None,
start_date: str = None,
end_date: str = None,
params: Optional[Mapping[str, Any]] = {},
):
"""
Retrieve property events for given parameters.
"""
if event_type:
if event_type not in VALID_EVENT_TYPES:
raise ValueError(
f"event_type value error. Valid values are: {VALID_EVENT_TYPES}. Received: {event_type}"
)
else:
params["event_type"] = event_type
parcl_property_ids = [str(i) for i in parcl_property_ids]
data_container = []
with alive_bar(len(parcl_property_ids)) as bar:
for i in range(0, len(parcl_property_ids), CHUNK_SIZE):
batch_ids = parcl_property_ids[i : i + CHUNK_SIZE]
params = {
"parcl_property_id": batch_ids,
"start_date": start_date,
"end_date": end_date,
**(params or {}),
}
batch_results = self._sync_request(params=params, method="POST")
for result in batch_results:
if result is None:
continue
bar()
data = self._as_pd_dataframe(batch_results)
data_container.append(data)

output = safe_concat_and_format_dtypes(data_container)
return output
78 changes: 78 additions & 0 deletions parcllabs/services/property_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import pandas as pd
from typing import Any, Mapping, Optional, List
from parcllabs.common import (
DEFAULT_LIMIT,
VALID_PROPERTY_TYPES,
)
from parcllabs.services.parcllabs_service import ParclLabsService


class PropertySearch(ParclLabsService):
"""
Retrieve parcl_property_id for geographic markets in the Parcl Labs API.
"""

def __init__(self, limit: int = DEFAULT_LIMIT, *args, **kwargs):
super().__init__(limit=limit, *args, **kwargs)

def _as_pd_dataframe(self, data: List[Mapping[str, Any]]) -> Any:
return pd.DataFrame(data)

def retrieve(
self,
zip: str,
sq_ft_min: int = None,
sq_ft_max: int = None,
bedrooms_min: int = None,
bedrooms_max: int = None,
bathrooms_min: int = None,
bathrooms_max: int = None,
year_built_min: int = None,
year_built_max: int = None,
property_type: str = None,
params: Optional[Mapping[str, Any]] = None,
):
"""
Retrieve parcl_id and metadata for geographic markets in the Parcl Labs API.

Args:

zip (str): The 5 digit zip code to filter results by.
sq_ft_min (int, optional): The minimum square footage to filter results by.
sq_ft_max (int, optional): The maximum square footage to filter results by.
bedrooms_min (int, optional): The minimum number of bedrooms to filter results by.
bedrooms_max (int, optional): The maximum number of bedrooms to filter results by.
bathrooms_min (int, optional): The minimum number of bathrooms to filter results by.
bathrooms_max (int, optional): The maximum number of bathrooms to filter results by.
year_built_min (int, optional): The minimum year built to filter results by.
year_built_max (int, optional): The maximum year built to filter results by.
property_type (str, optional): The property type to filter results by.
params (dict, optional): Additional parameters to include in the request.
auto_paginate (bool, optional): Automatically paginate through the results.

Returns:

Any: The JSON response as a pandas DataFrame.
"""

if property_type and property_type not in VALID_PROPERTY_TYPES:
raise ValueError(
f"property_type value error. Valid values are: {VALID_PROPERTY_TYPES}. Received: {property_type}"
)

params = {
"zip5": zip,
"square_footage_min": sq_ft_min,
"square_footage_max": sq_ft_max,
"bedrooms_min": bedrooms_min,
"bedrooms_max": bedrooms_max,
"bathrooms_min": bathrooms_min,
"bathrooms_max": bathrooms_max,
"year_built_min": year_built_min,
"year_built_max": year_built_max,
"property_type": property_type,
**(params or {}),
}
results = self._sync_request(params=params)
data = self._as_pd_dataframe(results)
return data
2 changes: 1 addition & 1 deletion parcllabs/services/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def retrieve(

Returns:

Any: The JSON response as a dictionary or a pandas DataFrame if as_dataframe is True.
Any: The JSON response as a pandas DataFrame.
"""

if location_type and location_type not in VALID_LOCATION_TYPES:
Expand Down
Loading