Skip to content

Commit

Permalink
Enable RetsClient instantiation from metadata dict (#9)
Browse files Browse the repository at this point in the history
* Rename _http_get -> _http_post

* Raise KeyError instead of returning None on unknown types

* Instantiate rets clients from metadata

* pass args through
  • Loading branch information
Martin Liu authored Jun 7, 2017
1 parent bfd666e commit 0499830
Show file tree
Hide file tree
Showing 6 changed files with 138 additions and 44 deletions.
70 changes: 60 additions & 10 deletions rets/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,76 @@
from rets.client.resource import Resource
from rets.http import RetsHttpClient

"""
Example of metadata dict:
metadata = [{
'ResourceID': 'Property',
'KeyField': 'Matrix_Unique_ID',
'_classes': [
{
'ClassName': 'Listing',
'HasKeyIndex': '1',
'_table': [
... column fields
],
}
],
'_object_types': [
'ObjectType': 'LargePhoto',
'MIMEType': 'image/jpeg',
]
}, {
'ResourceID': 'Agent',
'KeyField': 'Matrix_Unique_ID',
'_classes': [
{
'ClassName': 'Listing',
'HasKeyIndex': '1',
}
],
}]
"""


class RetsClient:

def __init__(self, *args, http_client: RetsHttpClient = None, metadata: dict = None, **kwargs):
self._http = http_client or RetsHttpClient(*args, **kwargs)
self._http.login()
self._metadata = metadata or {}
def __init__(self,
*args,
http_client: RetsHttpClient = None,
metadata: Sequence[dict] = (),
capability_urls: dict = None,
cookie_dict: dict = None,
**kwargs):
self.http = http_client or RetsHttpClient(*args,
capability_urls=capability_urls, cookie_dict=cookie_dict,
**kwargs)
if not (capability_urls and cookie_dict):
self.http.login()
self._resources = self._resources_from_metadata(metadata)

@property
def metadata(self) -> Sequence[dict]:
return tuple(resource.metadata for resource in self._resources)

@property
def resources(self) -> Sequence[Resource]:
if '_resources' not in self._metadata:
self._metadata['_resources'] = self._fetch_resources()
return self._metadata['_resources']
if not self._resources:
# TODO(ML) Differentiate between not having the metadata and
# having an empty metadata
self._resources = self._fetch_resources()
return self._resources

def get_resource(self, name: str) -> Optional[Resource]:
for resource in self.resources:
if resource.name == name:
return resource
return None
raise KeyError('unknown resource %s' % name)

def _fetch_resources(self) -> Sequence[Resource]:
metadata = self._http.get_metadata('resource')[0].data
return tuple(Resource(m, self._http) for m in metadata)
# Sends get_metadata request to RETS server
metadata = self.http.get_metadata('resource')[0].data
return self._resources_from_metadata(metadata)

def _resources_from_metadata(self, metadata: Sequence[dict]) -> Sequence[Resource]:
return tuple(Resource(m, self.http) for m in metadata)
4 changes: 4 additions & 0 deletions rets/client/object_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ def mime_type(self) -> str:
return self._metadata['MIMEType']
return self._metadata['MimeType']

@property
def metadata(self) -> dict:
return dict(self._metadata)

def get(self, resource_keys: Union[str, Mapping[str, Any], Sequence[str]],
**kwargs) -> Sequence[Object]:
return self._http.get_object(self.resource.name, self.name, resource_keys, **kwargs)
Expand Down
37 changes: 27 additions & 10 deletions rets/client/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ class Resource:
def __init__(self, metadata: dict, http_client: RetsHttpClient):
self._http = http_client
self._metadata = metadata
self._classes = self._classes_from_metadata(metadata.get('_classes', ()))
self._object_types = self._object_types_from_metadata(metadata.get('_object_types', ()))

@property
def name(self) -> str:
Expand All @@ -19,37 +21,52 @@ def name(self) -> str:
def key_field(self) -> str:
return self._metadata['KeyField']

@property
def metadata(self) -> dict:
metadata = dict(self._metadata)
if self._classes:
metadata['_classes'] = tuple(resource_class.metadata for resource_class in self._classes)
if self._object_types:
metadata['_object_types'] = tuple(object_type.metadata for object_type in self._object_types)
return metadata

@property
def classes(self) -> Sequence[ResourceClass]:
if '_classes' not in self._metadata:
self._metadata['_classes'] = self._fetch_classes()
return self._metadata['_classes']
if not self._classes:
self._classes = self._fetch_classes()
return self._classes

def get_class(self, name: str) -> Optional[ResourceClass]:
for resource_class in self.classes:
if resource_class.name == name:
return resource_class
return None
raise KeyError('unknown class %s' % name)

@property
def object_types(self) -> Sequence[ObjectType]:
if '_object_types' not in self._metadata:
self._metadata['_object_types'] = self._fetch_object_types()
return self._metadata['_object_types']
if not self._object_types:
self._object_types = self._fetch_object_types()
return self._object_types

def get_object_type(self, name: str) -> Optional[ObjectType]:
for resource_object in self.object_types:
if resource_object.name == name:
return resource_object
return None
raise KeyError('unknown object type %s' % name)

def _fetch_classes(self) -> Sequence[ResourceClass]:
metadata = self._http.get_metadata('class', resource=self.name)[0].data
return tuple(ResourceClass(self, m, self._http) for m in metadata)
return self._classes_from_metadata(metadata)

def _fetch_object_types(self) -> Sequence[ObjectType]:
metadata = self._http.get_metadata('object', resource=self.name)[0].data
return tuple(ObjectType(self, m, self._http) for m in metadata)
return self._object_types_from_metadata(metadata)

def _classes_from_metadata(self, classes_metadata: Sequence[dict]) -> Sequence[ResourceClass]:
return tuple(ResourceClass(self, m, self._http) for m in classes_metadata)

def _object_types_from_metadata(self, object_types_metadata: Sequence[dict]) -> Sequence[ObjectType]:
return tuple(ObjectType(self, m, self._http) for m in object_types_metadata)

def __repr__(self) -> str:
return '<Resource: %s>' % self.name
27 changes: 20 additions & 7 deletions rets/client/resource_class.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Mapping, Sequence, Union
from typing import Mapping, Sequence, Union, Optional

from rets.client.table import Table
from rets.client.record import Record
Expand All @@ -12,6 +12,7 @@ def __init__(self, resource, metadata: dict, http_client: RetsHttpClient):
self.resource = resource
self._http = http_client
self._metadata = metadata
self._table = self._table_from_metadata(metadata.get('_table', {}))

@property
def name(self) -> str:
Expand All @@ -21,11 +22,18 @@ def name(self) -> str:
def has_key_index(self) -> bool:
return 'HasKeyIndex' in self._metadata and self._metadata['HasKeyIndex'] == '1'

@property
def metadata(self) -> dict:
metadata = dict(self._metadata)
if self._table:
metadata['_table'] = self._table.metadata
return metadata

@property
def table(self) -> Table:
if '_table' not in self._metadata:
self._metadata['_table'] = self._fetch_table()
return self._metadata['_table']
if not self._table:
self._table = self._fetch_table()
return self._table

def search(self,
query: Union[str, Mapping[str, str]],
Expand All @@ -51,9 +59,14 @@ def search(self,
)

def _fetch_table(self) -> Table:
metadata = self._http.get_metadata('table', resource=self.resource.name,
class_=self.name)[0].data
return Table(self, metadata)
table_metadata = self._http.get_metadata('table', resource=self.resource.name,
class_=self.name)[0].data
return self._table_from_metadata(table_metadata)

def _table_from_metadata(self, table_metadata: Sequence[dict]) -> Optional[Table]:
if not table_metadata:
return None
return Table(self, table_metadata)

def _validate_query(self, query: Union[str, Mapping[str, str]]) -> str:
if isinstance(query, str):
Expand Down
4 changes: 4 additions & 0 deletions rets/client/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ def __init__(self, resource_class, fields: Sequence[dict]):
for field in self._fields
}

@property
def metadata(self) -> Sequence[dict]:
return tuple(self._fields)

@property
def fields(self) -> Set[str]:
return set(self._parsers)
Expand Down
40 changes: 23 additions & 17 deletions rets/http/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def __init__(self,
user_agent: str = 'rets-python/0.3',
user_agent_password: str = None,
rets_version: str = '1.7.2',
capability_urls: str = None,
cookie_dict: dict = None
):
self._user_agent = user_agent
Expand All @@ -35,7 +36,7 @@ def __init__(self,

splits = urlsplit(login_url)
self._base_url = urlunsplit((splits.scheme, splits.netloc, '', '', ''))
self._capabilities = {
self._capabilities = capability_urls or {
'Login': splits.path,
}

Expand All @@ -46,13 +47,14 @@ def __init__(self,
self._http_auth = None

# we use a session to keep track of cookies that are required for certain MLSes
self._session = None
self._session = requests.Session()

# The user may provide an optional cookie_dict argument, which will be used on first login.
# When sending cookies (with a session_id) to the login url, the same cookie (session_id)
# is returned, which (most likely) means no additional login is created. On logout,
# this str is destroyed, and login will fetch a new cookies
self._cookie_dict = cookie_dict
# is returned, which (most likely) means no additional login is created.
if cookie_dict:
for name, value in cookie_dict.items():
self._session.cookies.set(name, value=value)

# this session id is part of the rets standard for use with a user agent password
self._rets_session_id = ''
Expand All @@ -78,19 +80,22 @@ def rets_version(self) -> str:
"""
return 'RETS/' + self._rets_version

@property
def capability_urls(self) -> dict:
return self._capabilities

@property
def cookie_dict(self) -> dict:
return dict(self._session.cookies)

def login(self) -> dict:
self._session = requests.Session()
if self._cookie_dict:
for name, value in self._cookie_dict.items():
self._session.cookies.set(name, value=value)
response = self._http_get(self._url_for('Login'))
response = self._http_post(self._url_for('Login'))
self._capabilities = parse_capability_urls(response)
return self._capabilities

def logout(self) -> None:
self._http_get(self._url_for('Logout'))
self._http_post(self._url_for('Logout'))
self._session = None
self._cookie_dict = None

def get_system_metadata(self) -> SystemMetadata:
return parse_system(self._get_metadata('system'))
Expand Down Expand Up @@ -125,7 +130,7 @@ def _get_metadata(self, type_: str, metadata_id: str = '0') -> Response:
'id': metadata_id,
'Format': 'COMPACT',
}
return self._http_get(self._url_for('GetMetadata'), payload=payload)
return self._http_post(self._url_for('GetMetadata'), payload=payload)

def search(self,
resource: str,
Expand Down Expand Up @@ -203,7 +208,7 @@ def search(self,
# None values indicate that the argument should be omitted from the request
payload = {k: v for k, v in raw_payload.items() if v is not None}

rets_response = self._http_get(self._url_for('Search'), payload=payload)
rets_response = self._http_post(self._url_for('Search'), payload=payload)
return parse_search(rets_response)

def get_object(self,
Expand Down Expand Up @@ -249,7 +254,7 @@ def get_object(self,
:param location: Flag to indicate whether the object or a URL to the object should be
returned. If location is set to True, it is up to the server to support this
functionality and the lifetime of the return URL is not given by the RETS
functionality and the lifetime of the returned URL is not given by the RETS
specification.
"""
headers = {
Expand All @@ -261,7 +266,7 @@ def get_object(self,
'ID': _build_entity_object_ids(resource_keys),
'Location': int(location),
}
response = self._http_get(self._url_for('GetObject'), headers=headers, payload=payload)
response = self._http_post(self._url_for('GetObject'), headers=headers, payload=payload)
return parse_object(response)

def _url_for(self, transaction: str) -> str:
Expand All @@ -271,9 +276,10 @@ def _url_for(self, transaction: str) -> str:
raise RetsClientError('No URL found for transaction %s' % transaction)
return urljoin(self._base_url, url)

def _http_get(self, url: str, headers: dict = None, payload: dict = None) -> Response:
def _http_post(self, url: str, headers: dict = None, payload: dict = None) -> Response:
if not self._session:
raise RetsClientError('Session not instantiated. Call .login() first')

if headers is None:
headers = {}
else:
Expand Down

0 comments on commit 0499830

Please sign in to comment.