diff --git a/Pipfile.lock b/Pipfile.lock index 3ee1e97..33bb5dd 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "eca607e1ebb4ac0217479e002b5bcc943eff7450c15e24a5a6b8822f3c4ad7bd" + "sha256": "5263a8e31ab0bffc7b98243caf57303804f1ba0d109c3fcc458185ca25bb21ff" }, "pipfile-spec": 6, "requires": { @@ -104,7 +104,6 @@ "sha256:ff84aeb864e0fac81f676be9f4685f0527b660f1efdc40dcede3c251ef1e867f" ], "index": "pypi", - "markers": "python_version >= '3.8'", "version": "==3.9.5" }, "aiosignal": { @@ -121,9 +120,16 @@ "sha256:42e399a66c8150dc507602dff7b7953f105ef11faf97ddaa6d27b1cbf45c4c98" ], "index": "pypi", - "markers": "python_version >= '3.7' and python_version < '4'", "version": "==3.1.5" }, + "async-timeout": { + "hashes": [ + "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f", + "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028" + ], + "markers": "python_version < '3.11'", + "version": "==4.0.3" + }, "attrs": { "hashes": [ "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30", @@ -447,7 +453,6 @@ "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c" ], "index": "pypi", - "markers": "python_version >= '3.5'", "version": "==1.6.0" }, "numpy": { @@ -499,7 +504,6 @@ "sha256:feff59f27338135776f6d4e2ec7aeeac5d5f7a08a83e80869121ef8164b74af9" ], "index": "pypi", - "markers": "python_version >= '3.9'", "version": "==2.0.0" }, "packaging": { @@ -543,7 +547,6 @@ "sha256:eee3a87076c0756de40b05c5e9a6069c035ba43e8dd71c379e68cab2c20f16ad" ], "index": "pypi", - "markers": "python_version >= '3.9'", "version": "==2.2.2" }, "plotly": { @@ -552,7 +555,6 @@ "sha256:859fdadbd86b5770ae2466e542b761b247d1c6b49daed765b95bb8c7063e7469" ], "index": "pypi", - "markers": "python_version >= '3.8'", "version": "==5.22.0" }, "python-dateutil": { @@ -576,7 +578,6 @@ "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6" ], "index": "pypi", - "markers": "python_version >= '3.8'", "version": "==2.32.3" }, "six": { @@ -589,11 +590,11 @@ }, "tenacity": { "hashes": [ - "sha256:28522e692eda3e1b8f5e99c51464efcc0b9fc86933da92415168bc1c4e2308fa", - "sha256:54b1412b878ddf7e1f1577cd49527bad8cdef32421bd599beac0c6c3f10582fd" + "sha256:9e6f7cf7da729125c7437222f8a522279751cdfbe6b67bfe64f75d3a348661b2", + "sha256:cd80a53a79336edba8489e767f729e4f391c896956b57140b5d7511a64bbd3ef" ], "markers": "python_version >= '3.8'", - "version": "==8.4.1" + "version": "==8.4.2" }, "tzdata": { "hashes": [ @@ -797,7 +798,6 @@ "sha256:ff84aeb864e0fac81f676be9f4685f0527b660f1efdc40dcede3c251ef1e867f" ], "index": "pypi", - "markers": "python_version >= '3.8'", "version": "==3.9.5" }, "aiosignal": { @@ -814,7 +814,6 @@ "sha256:42e399a66c8150dc507602dff7b7953f105ef11faf97ddaa6d27b1cbf45c4c98" ], "index": "pypi", - "markers": "python_version >= '3.7' and python_version < '4'", "version": "==3.1.5" }, "appnope": { @@ -832,13 +831,20 @@ ], "version": "==2.4.1" }, + "async-timeout": { + "hashes": [ + "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f", + "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028" + ], + "markers": "python_version < '3.11'", + "version": "==4.0.3" + }, "asynctest": { "hashes": [ "sha256:5da6118a7e6d6b54d83a8f7197769d046922a44d2a99c21382f0a6e4fadae676", "sha256:c27862842d15d83e6a34eb0b2866c323880eb3a75e4485b079ea11748fd77fac" ], "index": "pypi", - "markers": "python_version >= '3.5'", "version": "==0.13.0" }, "attrs": { @@ -875,7 +881,6 @@ "sha256:ef703f83fc32e131e9bcc0a5094cfe85599e7109f896fe8bc96cc402f3eb4b6e" ], "index": "pypi", - "markers": "python_version >= '3.8'", "version": "==24.4.2" }, "certifi": { @@ -1000,31 +1005,31 @@ }, "debugpy": { "hashes": [ - "sha256:016a9fcfc2c6b57f939673c874310d8581d51a0fe0858e7fac4e240c5eb743cb", - "sha256:0de56aba8249c28a300bdb0672a9b94785074eb82eb672db66c8144fff673146", - "sha256:1a9fe0829c2b854757b4fd0a338d93bc17249a3bf69ecf765c61d4c522bb92a8", - "sha256:28acbe2241222b87e255260c76741e1fbf04fdc3b6d094fcf57b6c6f75ce1242", - "sha256:3a79c6f62adef994b2dbe9fc2cc9cc3864a23575b6e387339ab739873bea53d0", - "sha256:3bda0f1e943d386cc7a0e71bfa59f4137909e2ed947fb3946c506e113000f741", - "sha256:3ebb70ba1a6524d19fa7bb122f44b74170c447d5746a503e36adc244a20ac539", - "sha256:58911e8521ca0c785ac7a0539f1e77e0ce2df753f786188f382229278b4cdf23", - "sha256:6df9aa9599eb05ca179fb0b810282255202a66835c6efb1d112d21ecb830ddd3", - "sha256:7a3afa222f6fd3d9dfecd52729bc2e12c93e22a7491405a0ecbf9e1d32d45b39", - "sha256:7eb7bd2b56ea3bedb009616d9e2f64aab8fc7000d481faec3cd26c98a964bcdd", - "sha256:92116039b5500633cc8d44ecc187abe2dfa9b90f7a82bbf81d079fcdd506bae9", - "sha256:a2e658a9630f27534e63922ebf655a6ab60c370f4d2fc5c02a5b19baf4410ace", - "sha256:bfb20cb57486c8e4793d41996652e5a6a885b4d9175dd369045dad59eaacea42", - "sha256:caad2846e21188797a1f17fc09c31b84c7c3c23baf2516fed5b40b378515bbf0", - "sha256:d915a18f0597ef685e88bb35e5d7ab968964b7befefe1aaea1eb5b2640b586c7", - "sha256:dda73bf69ea479c8577a0448f8c707691152e6c4de7f0c4dec5a4bc11dee516e", - "sha256:e38beb7992b5afd9d5244e96ad5fa9135e94993b0c551ceebf3fe1a5d9beb234", - "sha256:edcc9f58ec0fd121a25bc950d4578df47428d72e1a0d66c07403b04eb93bcf98", - "sha256:efd3fdd3f67a7e576dd869c184c5dd71d9aaa36ded271939da352880c012e703", - "sha256:f696d6be15be87aef621917585f9bb94b1dc9e8aced570db1b8a6fc14e8f9b42", - "sha256:fd97ed11a4c7f6d042d320ce03d83b20c3fb40da892f994bc041bbc415d7a099" + "sha256:0600faef1d0b8d0e85c816b8bb0cb90ed94fc611f308d5fde28cb8b3d2ff0fe3", + "sha256:1523bc551e28e15147815d1397afc150ac99dbd3a8e64641d53425dba57b0ff9", + "sha256:15bc2f4b0f5e99bf86c162c91a74c0631dbd9cef3c6a1d1329c946586255e859", + "sha256:16c8dcab02617b75697a0a925a62943e26a0330da076e2a10437edd9f0bf3755", + "sha256:16e16df3a98a35c63c3ab1e4d19be4cbc7fdda92d9ddc059294f18910928e0ca", + "sha256:2cbd4d9a2fc5e7f583ff9bf11f3b7d78dfda8401e8bb6856ad1ed190be4281ad", + "sha256:3f8c3f7c53130a070f0fc845a0f2cee8ed88d220d6b04595897b66605df1edd6", + "sha256:40f062d6877d2e45b112c0bbade9a17aac507445fd638922b1a5434df34aed02", + "sha256:5a019d4574afedc6ead1daa22736c530712465c0c4cd44f820d803d937531b2d", + "sha256:5d3ccd39e4021f2eb86b8d748a96c766058b39443c1f18b2dc52c10ac2757835", + "sha256:62658aefe289598680193ff655ff3940e2a601765259b123dc7f89c0239b8cd3", + "sha256:7ee2e1afbf44b138c005e4380097d92532e1001580853a7cb40ed84e0ef1c3d2", + "sha256:7f8d57a98c5a486c5c7824bc0b9f2f11189d08d73635c326abef268f83950326", + "sha256:8a13417ccd5978a642e91fb79b871baded925d4fadd4dfafec1928196292aa0a", + "sha256:95378ed08ed2089221896b9b3a8d021e642c24edc8fef20e5d4342ca8be65c00", + "sha256:acdf39855f65c48ac9667b2801234fc64d46778021efac2de7e50907ab90c634", + "sha256:bd11fe35d6fd3431f1546d94121322c0ac572e1bfb1f6be0e9b8655fb4ea941e", + "sha256:c78ba1680f1015c0ca7115671fe347b28b446081dada3fedf54138f44e4ba031", + "sha256:cf327316ae0c0e7dd81eb92d24ba8b5e88bb4d1b585b5c0d32929274a66a5210", + "sha256:d3408fddd76414034c02880e891ea434e9a9cf3a69842098ef92f6e809d09afa", + "sha256:e24ccb0cd6f8bfaec68d577cb49e9c680621c336f347479b3fce060ba7c09ec1", + "sha256:f179af1e1bd4c88b0b9f0fa153569b24f6b6f3de33f94703336363ae62f4bf47" ], "markers": "python_version >= '3.8'", - "version": "==1.8.1" + "version": "==1.8.2" }, "decorator": { "hashes": [ @@ -1034,6 +1039,14 @@ "markers": "python_version >= '3.5'", "version": "==5.1.1" }, + "exceptiongroup": { + "hashes": [ + "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad", + "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16" + ], + "markers": "python_version < '3.11'", + "version": "==1.2.1" + }, "executing": { "hashes": [ "sha256:35afe2ce3affba8ee97f2d69927fa823b08b472b7b994e36a52a964b93d16147", @@ -1146,6 +1159,14 @@ "markers": "python_version >= '3.5'", "version": "==3.7" }, + "importlib-metadata": { + "hashes": [ + "sha256:15584cf2b1bf449d98ff8a6ff1abef57bf20f3ac6454f431736cd3e660921b2f", + "sha256:188bd24e4c346d3f0a933f275c2fec67050326a856b9a359881d7c2a697e8812" + ], + "markers": "python_version < '3.10'", + "version": "==8.0.0" + }, "iniconfig": { "hashes": [ "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", @@ -1156,20 +1177,19 @@ }, "ipykernel": { "hashes": [ - "sha256:1181e653d95c6808039c509ef8e67c4126b3b3af7781496c7cbfb5ed938a27da", - "sha256:3d44070060f9475ac2092b760123fadf105d2e2493c24848b6691a7c4f42af5c" + "sha256:afdb66ba5aa354b09b91379bac28ae4afebbb30e8b39510c9690afb7a10421b5", + "sha256:f093a22c4a40f8828f8e330a9c297cb93dcab13bd9678ded6de8e5cf81c56215" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==6.29.4" + "version": "==6.29.5" }, "ipython": { "hashes": [ - "sha256:53eee7ad44df903a06655871cbab66d156a051fd86f3ec6750470ac9604ac1ab", - "sha256:c6ed726a140b6e725b911528f80439c534fac915246af3efc39440a6b0f9d716" + "sha256:ca6f079bb33457c66e233e4580ebfc4128855b4cf6370dddd73842a9563e8a27", + "sha256:e8267419d72d81955ec1177f8a29aaa90ac80ad647499201119e2f05e99aa397" ], - "markers": "python_version >= '3.10'", - "version": "==8.25.0" + "markers": "python_version >= '3.9'", + "version": "==8.18.1" }, "jedi": { "hashes": [ @@ -1341,7 +1361,6 @@ "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b" ], "index": "pypi", - "markers": "python_version >= '3.8'", "version": "==5.10.4" }, "nest-asyncio": { @@ -1350,7 +1369,6 @@ "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c" ], "index": "pypi", - "markers": "python_version >= '3.5'", "version": "==1.6.0" }, "numpy": { @@ -1402,7 +1420,6 @@ "sha256:feff59f27338135776f6d4e2ec7aeeac5d5f7a08a83e80869121ef8164b74af9" ], "index": "pypi", - "markers": "python_version >= '3.9'", "version": "==2.0.0" }, "packaging": { @@ -1446,7 +1463,6 @@ "sha256:eee3a87076c0756de40b05c5e9a6069c035ba43e8dd71c379e68cab2c20f16ad" ], "index": "pypi", - "markers": "python_version >= '3.9'", "version": "==2.2.2" }, "parcllabs": { @@ -1474,7 +1490,7 @@ "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f" ], - "markers": "sys_platform != 'win32' and sys_platform != 'emscripten'", + "markers": "sys_platform != 'win32'", "version": "==4.9.0" }, "platformdirs": { @@ -1491,7 +1507,6 @@ "sha256:859fdadbd86b5770ae2466e542b761b247d1c6b49daed765b95bb8c7063e7469" ], "index": "pypi", - "markers": "python_version >= '3.8'", "version": "==5.22.0" }, "pluggy": { @@ -1561,7 +1576,6 @@ "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977" ], "index": "pypi", - "markers": "python_version >= '3.8'", "version": "==8.2.2" }, "pytest-asyncio": { @@ -1570,7 +1584,6 @@ "sha256:5f5c72948f4c49e7db4f29f2521d4031f1c27f86e57b046126654083d4770268" ], "index": "pypi", - "markers": "python_version >= '3.8'", "version": "==0.23.7" }, "python-dateutil": { @@ -1696,7 +1709,6 @@ "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6" ], "index": "pypi", - "markers": "python_version >= '3.8'", "version": "==2.32.3" }, "requests-mock": { @@ -1705,7 +1717,6 @@ "sha256:e9e12e333b525156e82a3c852f22016b9158220d2f47454de9cae8a77d371401" ], "index": "pypi", - "markers": "python_version >= '3.5'", "version": "==1.12.1" }, "rpds-py": { @@ -1830,11 +1841,19 @@ }, "tenacity": { "hashes": [ - "sha256:28522e692eda3e1b8f5e99c51464efcc0b9fc86933da92415168bc1c4e2308fa", - "sha256:54b1412b878ddf7e1f1577cd49527bad8cdef32421bd599beac0c6c3f10582fd" + "sha256:9e6f7cf7da729125c7437222f8a522279751cdfbe6b67bfe64f75d3a348661b2", + "sha256:cd80a53a79336edba8489e767f729e4f391c896956b57140b5d7511a64bbd3ef" ], "markers": "python_version >= '3.8'", - "version": "==8.4.1" + "version": "==8.4.2" + }, + "tomli": { + "hashes": [ + "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", + "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" + ], + "markers": "python_version < '3.11'", + "version": "==2.0.1" }, "tornado": { "hashes": [ @@ -1861,6 +1880,14 @@ "markers": "python_version >= '3.8'", "version": "==5.14.3" }, + "typing-extensions": { + "hashes": [ + "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", + "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8" + ], + "markers": "python_version < '3.11'", + "version": "==4.12.2" + }, "tzdata": { "hashes": [ "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd", @@ -1979,6 +2006,14 @@ ], "markers": "python_version >= '3.7'", "version": "==1.9.4" + }, + "zipp": { + "hashes": [ + "sha256:bf1dcf6450f873a13e952a29504887c89e6de7506209e5b1bcc3460135d4de19", + "sha256:f091755f667055f2d02b32c53771a7a6c8b47e1fdbc4b72a8b9072b3eef8015c" + ], + "markers": "python_version >= '3.8'", + "version": "==3.19.2" } } } diff --git a/README.md b/README.md index 579b8fe..dc46d33 100644 --- a/README.md +++ b/README.md @@ -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? diff --git a/parcllabs/__version__.py b/parcllabs/__version__.py index ca638cf..848258f 100644 --- a/parcllabs/__version__.py +++ b/parcllabs/__version__.py @@ -1 +1 @@ -VERSION = "1.1.1" +VERSION = "1.2.0" diff --git a/parcllabs/common.py b/parcllabs/common.py index a72a42a..39d230d 100644 --- a/parcllabs/common.py +++ b/parcllabs/common.py @@ -163,3 +163,5 @@ ] VALID_SORT_ORDER = ["ASC", "DESC"] + +VALID_EVENT_TYPES = ["SALE", "LISTING", "RENTAL", "ALL"] diff --git a/parcllabs/parcllabs_client.py b/parcllabs/parcllabs_client.py index 6775d95..45e5b25 100644 --- a/parcllabs/parcllabs_client.py +++ b/parcllabs/parcllabs_client.py @@ -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 @@ -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 + ) diff --git a/parcllabs/services/parcllabs_service.py b/parcllabs/services/parcllabs_service.py index 54ca2b2..5de4067 100644 --- a/parcllabs/services/parcllabs_service.py +++ b/parcllabs/services/parcllabs_service.py @@ -177,6 +177,7 @@ def _sync_request( url: str = None, params: Optional[Mapping[str, Any]] = None, is_next: bool = False, + method: str = "GET", ) -> Any: if url: url = url @@ -184,7 +185,32 @@ def _sync_request( 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 team@parcllabs.com." + 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): """ @@ -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 team@parcllabs.com." - 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: diff --git a/parcllabs/services/property_events_service.py b/parcllabs/services/property_events_service.py new file mode 100644 index 0000000..cf3db33 --- /dev/null +++ b/parcllabs/services/property_events_service.py @@ -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 diff --git a/parcllabs/services/property_search.py b/parcllabs/services/property_search.py new file mode 100644 index 0000000..a809768 --- /dev/null +++ b/parcllabs/services/property_search.py @@ -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 diff --git a/parcllabs/services/search.py b/parcllabs/services/search.py index 238a3b4..33acc01 100644 --- a/parcllabs/services/search.py +++ b/parcllabs/services/search.py @@ -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: diff --git a/tests/test_property.py b/tests/test_property.py new file mode 100644 index 0000000..22fc2b0 --- /dev/null +++ b/tests/test_property.py @@ -0,0 +1,109 @@ +import pytest +from parcllabs.services.property_search import PropertySearch +from parcllabs.services.property_events_service import PropertyEventsService +from unittest.mock import MagicMock + +# Mock Data for testing +mock_search_response = { + "parcl_property_id": [123456, 456789], +} +mock_event_response = [ + { + "property": { + "parcl_property_id": 123456, + "address": "123 Main St", + "unit": "#123", + "city": "NEW YORK", + "state_abbreviation": "NY", + "zip5": "10001", + "zip4": "5509", + "latitude": 123.123, + "longitude": -123.123, + "property_type": "CONDO", + "bedrooms": 0, + "bathrooms": 0.0, + "square_footage": 2000, + "year_built": 2020, + }, + "events": [ + { + "event_date": "2023-03-04", + "event_type": "RENTAL", + "event_name": "LISTED_RENT", + "price": 6995.0, + }, + { + "event_date": "2022-10-21", + "event_type": "RENTAL", + "event_name": "LISTING_REMOVED", + "price": None, + }, + { + "event_date": "2020-09-21", + "event_type": "RENTAL", + "event_name": "LISTED_RENT", + "price": 7000.0, + }, + ], + }, +] + + +@pytest.fixture +def property_search_service(): + client_mock = MagicMock() + client_mock.api_url = "https://api.parcllabs.com" + client_mock.api_key = "test_api_key" + service = PropertySearch(client=client_mock, url="v1//property/search_markets") + service._sync_request = MagicMock(return_value=mock_search_response) + return service + + +@pytest.fixture +def property_events_service(): + client_mock = MagicMock() + client_mock.api_url = "https://api.parcllabs.com" + client_mock.api_key = "test_api_key" + service = PropertyEventsService( + client=client_mock, url="/v1/property/event_history" + ) + service._sync_request = MagicMock(return_value=mock_event_response) + return service + + +def test_property_search_retrieve(property_search_service): + result = property_search_service.retrieve(zip="10001") + assert not result.empty + assert "parcl_property_id" in result.columns + assert len(result) == 2 + assert result.iloc[0]["parcl_property_id"] == 123456 + assert result.iloc[1]["parcl_property_id"] == 456789 + + +def test_validate_property_type(property_search_service): + with pytest.raises(ValueError): + property_search_service.retrieve(zip="10001", property_type="invalid_type") + + +def test_property_event_history_retrieve(property_events_service): + result = property_events_service.retrieve(parcl_property_ids=[123456]) + assert not result.empty + assert "parcl_property_id" in result.columns + assert "address" in result.columns + assert "event_date" in result.columns + assert "event_type" in result.columns + assert "event_name" in result.columns + assert len(result) == 3 + assert result.iloc[0]["parcl_property_id"] == 123456 + assert result.iloc[0]["event_date"] == "2023-03-04" + assert result.iloc[0]["event_type"] == "RENTAL" + assert result.iloc[1]["event_date"] == "2022-10-21" + assert result.iloc[1]["event_name"] == "LISTING_REMOVED" + assert result.iloc[2]["event_date"] == "2020-09-21" + + +def test_validate_event_type(property_events_service): + with pytest.raises(ValueError): + property_events_service.retrieve( + parcl_property_ids=[123456], event_type="invalid_type" + )