diff --git a/.gitignore b/.gitignore index 446ff23..6e0a76e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ lib include man share +__pycache__ +*.egg-info # Ignore Python byte compiled files and Windows GUI Python files *.pyc @@ -20,6 +22,8 @@ share # Ignore text editor backup files *~ +*.bak -# Ignore test/config.ini +# Ignore all config.ini file: they contain private config info test/config.ini +sample-apps/*/config.ini diff --git a/.travis.yml b/.travis.yml index ef2b550..e5035d4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,11 +5,12 @@ python: - "3.2" - "3.3" - "3.4" + - "3.5" install: pip install coveralls # SDK does not have any external dependencies. - + script: coverage run --source=familysearch setup.py test after_success: coveralls diff --git a/README.rst b/README.rst index 1f6bdf9..896c89a 100644 --- a/README.rst +++ b/README.rst @@ -4,19 +4,19 @@ familysearch-python-sdk-opensource .. image:: https://readthedocs.org/projects/familysearch-python-sdk-opensource/badge/?version=latest :target: https://readthedocs.org/projects/familysearch-python-sdk-opensource/?badge=latest :alt: Documentation Status - + .. image:: https://www.codacy.com/project/badge/4875862e69c54164be173a94def06f09 - :target: https://www.codacy.com/app/elderamevans/familysearch-python-sdk-opensource + :target: https://www.codacy.com/app/djhenderson/familysearch-python-sdk-opensource :alt: Codacy Badge -.. image:: https://travis-ci.org/elderamevans/familysearch-python-sdk-opensource.svg?branch=master - :target: https://travis-ci.org/elderamevans/familysearch-python-sdk-opensource +.. image:: https://travis-ci.org/djhenderson/familysearch-python-sdk-opensource.svg?branch=master + :target: https://travis-ci.org/djhenderson/familysearch-python-sdk-opensource :alt: Build status -.. image:: https://coveralls.io/repos/elderamevans/familysearch-python-sdk-opensource/badge.svg - :target: https://coveralls.io/r/elderamevans/familysearch-python-sdk-opensource +.. image:: https://coveralls.io/repos/djhenderson/familysearch-python-sdk-opensource/badge.svg + :target: https://coveralls.io/r/djhenderson/familysearch-python-sdk-opensource :alt: Code Coverage - + .. image:: https://img.shields.io/pypi/v/familysearch-python-sdk-opensource.svg :target: https://pypi.python.org/pypi/familysearch-python-sdk-opensource :alt: PyPI Version @@ -25,13 +25,12 @@ familysearch-python-sdk-opensource :target: https://pypi.python.org/pypi/familysearch-python-sdk-opensource -This is a reboot/spinoff of https://github.com/familysearch-devnet/python-fs-stack. +This is a reboot/spinoff of https://github.com/familysearch-devnet/python-fs-stack which was last updated about June 8, 2011. + +This is a fork of https://github.com/AmEv7Fam/familysearch-python-sdk-opensource which was last updated about July 21, 2015. May or may not have all of the latest FamilySearch endpoints in place, but it is flexible enough to allow for additional endpoints It is designed for Python 3 and 2.7. Python 2.6 is the earliest supported version, but bugfixes are low-priority. 2.5 and earlier are not guaranteed to work, or supported, and are considered legacy. -To get started, visit http://elderamevans.github.io/familysearch-python-sdk-opensource. - - - +To get started, visit http://elderamevans.github.io/familysearch-python-sdk-opensource (May be 404 Not found). diff --git a/docs/Authentication.md b/docs/Authentication.md index e69de29..8ac91d0 100644 --- a/docs/Authentication.md +++ b/docs/Authentication.md @@ -0,0 +1,23 @@ +* `login(self, username, password)` +Log into FamilySearch using Basic Authentication. +This mechanism is available only to approved developer keys. + +* `oauth_desktop_login(self, ruri=None)` +Log into FamilySearch using OAuth2 Authentication. +This is primarily a convenience function for desktop apps. +Not normally intended for production apps, but should +work while waiting for approval for password login. +Default Redirect URI is "http://localhost:63342/fslogin", +but you can set your own as a paramater. + +* `oauth_code_login(self, code)` +Convenience function for Web servers to log into FamilySearch +with the token code FamilySearch hands you. + +* `unauthenticated_login(self, ip_address)` +Log into FamilySearch without authenticating. +Has very limited read-only access. +Not intended for general use. + +* `logout(self)` +Log the current session out of FamilySearch. diff --git a/docs/Authorities.md b/docs/Authorities.md index e69de29..6a5b2f2 100644 --- a/docs/Authorities.md +++ b/docs/Authorities.md @@ -0,0 +1 @@ +* [`dates(self, **kwargs)`](https://familysearch.org/developers/docs/api/dates/Date_resource) diff --git a/docs/Discovery.md b/docs/Discovery.md index e69de29..099bc04 100644 --- a/docs/Discovery.md +++ b/docs/Discovery.md @@ -0,0 +1,5 @@ +* `update_collection(self, collection)` + +* `fix_discovery(self)` +The Hypermedia items are semi-permanent. Some things change +based on who's logged in (or out). diff --git a/docs/Memories.md b/docs/Memories.md index cf8841f..77406cd 100644 --- a/docs/Memories.md +++ b/docs/Memories.md @@ -1,7 +1,7 @@ * [`memories(**kwargs)`](https://familysearch.org/developers/docs/api/memories/Memories_resource) * [`memory(mid)`](https://familysearch.org/developers/docs/api/memories/Memory_resource) * [`user_memories(**kwargs)`](https://familysearch.org/developers/docs/api/memories/User_Memories_resource) -* [`memory_personas(mid):`](https://familysearch.org/developers/docs/api/memories/Memory_Personas_resource) +* [`memory_personas(mid)`](https://familysearch.org/developers/docs/api/memories/Memory_Personas_resource) * [`memory_persona(mid, pid)`](https://familysearch.org/developers/docs/api/memories/Memory_Persona_resource) * [`memory_comments(mid)`](https://familysearch.org/developers/docs/api/memories/Memory_Comments_resource) -* [`memories_comment(mid, cmid)`](https://familysearch.org/developers/docs/api/memories/Memories_Comment_resource) \ No newline at end of file +* [`memories_comment(mid, cmid)`](https://familysearch.org/developers/docs/api/memories/Memories_Comment_resource) diff --git a/docs/Person.md b/docs/Person.md index 0e6bbdb..4d8c56d 100644 --- a/docs/Person.md +++ b/docs/Person.md @@ -16,12 +16,12 @@ * [`person_discussion_reference(pid, drid)`](https://familysearch.org/developers/docs/api/tree/Person_Discussion_References_resource) * [`person_merge(pid, dpid, **kwargs)`](https://familysearch.org/developers/docs/api/tree/Person_Merge_resource) * [`person_change_summary(pid)`](https://familysearch.org/developers/docs/api/tree/Person_Merge_resource) -* [`person_not_a_match(pid, dpid):`](https://familysearch.org/developers/docs/api/tree/Person_Not_A_Match_resource) +* [`person_not_a_match(pid, dpid)`](https://familysearch.org/developers/docs/api/tree/Person_Not_A_Match_resource) * [`person_restore(pid)`](https://familysearch.org/developers/docs/api/tree/Person_Restore_resource) * [`preferred_spouse_relationship(pid)`](https://familysearch.org/developers/docs/api/tree/Preferred_Spouse_Relationship_resource) * [`preferred_parent_relationship(pid)`](https://familysearch.org/developers/docs/api/tree/Preferred_Parent_Relationship_resource) -* [`person_memories(pid)`](https://familysearch.org/developers/docs/api/tree/Person_Memories_resource) +* [`person_memories(pid, **kwargs)`](https://familysearch.org/developers/docs/api/tree/Person_Memories_resource) * [`person_memory_references(pid)`](https://familysearch.org/developers/docs/api/tree/Person_Memory_References_resource) * [`person_memory_reference(pid, erid)`](https://familysearch.org/developers/docs/api/tree/Person_Memory_References_resource) * [`person_memories_portrait(pid, **kwargs)`](https://familysearch.org/developers/docs/api/tree/Person_Memories_Portrait_resource) -* [`person_portraits(pid)`](https://familysearch.org/developers/docs/api/tree/Person_Portraits_resource) \ No newline at end of file +* [`person_portraits(pid)`](https://familysearch.org/developers/docs/api/tree/Person_Portraits_resource) diff --git a/docs/Places.md b/docs/Places.md index e98e0ae..72ed53f 100644 --- a/docs/Places.md +++ b/docs/Places.md @@ -3,7 +3,7 @@ * [`place_group(pgid)`](https://familysearch.org/developers/docs/api/places/Place_Group_resource) * [`place(pid)`](https://familysearch.org/developers/docs/api/places/Place_resource) * [`place_description_children()`](https://familysearch.org/developers/docs/api/places/Place_Description_Children_resource) -* [`place_type(ptid):`](https://familysearch.org/developers/docs/api/places/Place_Type_resource) +* [`place_type(ptid)`](https://familysearch.org/developers/docs/api/places/Place_Type_resource) * [`place_type_group(ptgid)`](https://familysearch.org/developers/docs/api/places/Place_Type_Group_resource) * [`place_types(self)`](https://familysearch.org/developers/docs/api/places/Place_Types_resource) * [`def place_type_groups(self):`](https://familysearch.org/developers/docs/api/places/Place_Type_Groups_resource) diff --git a/docs/Sources.md b/docs/Sources.md index 6cffa88..17939cd 100644 --- a/docs/Sources.md +++ b/docs/Sources.md @@ -1,8 +1,8 @@ * [`source_descriptions()`](https://familysearch.org/developers/docs/api/sources/Source_Descriptions_resource) -* [`source_description(sdid):`](https://familysearch.org/developers/docs/api/sources/Source_Description_resource) -* [`source_folders():`](https://familysearch.org/developers/docs/api/sources/Source_Folders_resource) -* [`source_folder(udcid):`](https://familysearch.org/developers/docs/api/sources/Source_Folder_resource) +* [`source_description(sdid)`](https://familysearch.org/developers/docs/api/sources/Source_Description_resource) +* [`source_folders()`](https://familysearch.org/developers/docs/api/sources/Source_Folders_resource) +* [`source_folder(udcid)`](https://familysearch.org/developers/docs/api/sources/Source_Folder_resource) * [`source_folder_source_descriptions(udcid, **kwargs)`](https://familysearch.org/developers/docs/api/sources/Source_Folder_Source_Descriptions_resource) * [`user_source_folders()`](https://familysearch.org/developers/docs/api/sources/User_Source_Folders_resource) -* [`user_source_descriptions(**kwargs):`](https://familysearch.org/developers/docs/api/sources/User_Source_Descriptions_resource) -* [`source_references_query(self, **kwargs):`](https://familysearch.org/developers/docs/api/tree/Source_References_Query_resource) \ No newline at end of file +* [`user_source_descriptions(**kwargs)`](https://familysearch.org/developers/docs/api/sources/User_Source_Descriptions_resource) +* [`source_references_query(self, **kwargs)`](https://familysearch.org/developers/docs/api/tree/Source_References_Query_resource) diff --git a/docs/index.md b/docs/index.md index 6d5c51c..f46a5c1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,5 +1,8 @@ -Modules are modeled after the layout of [the official FamilySearch API documentation](https://familysearch.org/developers/docs/api/resources). -If you're not sure which function you need, it's best to use the URL of the FamilySearch Developer documentation, and search in that section for that URL. +Modules are modeled after the layout of +[the official FamilySearch API documentation](https://familysearch.org/developers/docs/api/resources). +If you're not sure which function you need, +it's best to use the URL of the FamilySearch Developer documentation, +and search in that section for that URL. * [Main Module](Main_Module.md) * [Authentication](Authentication.md) diff --git a/familysearch/__init__.py b/familysearch/__init__.py index 9ab83d4..c2074f9 100644 --- a/familysearch/__init__.py +++ b/familysearch/__init__.py @@ -1,6 +1,9 @@ +# -*- coding: utf-8 -*- + # For PyLint users: -# I'll get back to you to the recommeneded command. -r"""This is a WIP, unofficial SDK for accessing +# I'll get back to you to the recommended command. + +"""This is a WIP, unofficial SDK for accessing the FamilySearch API. Currently designed to support Python 3.2+ and 2.6+. @@ -24,19 +27,20 @@ # Python imports - try: # Python 3 from urllib.request import Request as BaseRequest from urllib.request import build_opener from urllib.error import HTTPError from urllib.parse import(urlsplit, urlunsplit, parse_qs, urlencode) + PY3 = True except ImportError: # Python 2 from urllib import urlencode from urllib2 import Request as BaseRequest from urllib2 import build_opener, HTTPError from urlparse import(urlsplit, urlunsplit, parse_qs) + PY3 = False import json import time @@ -64,13 +68,14 @@ # Magic -__version__ = '1.2.0' +__version__ = '1.3.0alpha' class Request(BaseRequest): """Add ability for the Request object to allow it to handle additional methods. """ + def __init__(self, *args, **kwargs): self._method = kwargs.pop('method', None) BaseRequest.__init__(self, *args, **kwargs) @@ -79,6 +84,7 @@ def get_method(self): """The Request object has been enhanced to handle PUT, DELETE, OPTIONS, and HEAD request methods. """ + if self._method: return self._method else: @@ -96,22 +102,21 @@ class FamilySearch(Authentication, Authorities, ChangeHistory, Discovery, ...needs to be re-written... """ - def __init__(self, agent, key, session=None, + def __init__(self, agent, dev_key, session=None, base='https://sandbox.familysearch.org'): - """Instantiate a FamilySearch proxy object. Keyword arguments: agent -- User-agent string to use for requests - key -- FamilySearch developer key + dev_key -- FamilySearch developer key (optional if reusing an existing session ID) session (optional) -- existing session ID to reuse base (optional) -- base URL for the API; defaults to 'https://sandbox.familysearch.org' """ - self.agent = '%s FSPySDK/%s' % (agent, __version__) - self.key = key + self.user_agent = '%s FSPySDK/%s' % (agent, __version__) + self.dev_key = dev_key self.access_token = session self.base = base self.user_base = self.base + "/platform/users/" @@ -120,13 +125,15 @@ def __init__(self, agent, key, session=None, self.logged_in = bool(self.access_token) Discovery.__init__(self) - super().__init__() + if PY3: + super().__init__() + else: + super(FamilySearch, self).__init__() # Discovery needs to explicitly be first, as it is the hypermedia # engine. # There might be a better fix, but it's better than nothing. - def _request(self, url, data=None, headers=None, method=None, nojson=False): """ Make a request to the FamilySearch API. @@ -137,6 +144,7 @@ def _request(self, url, data=None, headers=None, method=None, nojson=False): Returns a file-like object representing the response. """ + if headers is None: headers = {} if data: @@ -148,26 +156,28 @@ def _request(self, url, data=None, headers=None, method=None, nojson=False): try: data = data.encode('utf-8') except AttributeError: - #Some things are not JSON, and need to be sent as bytes! + # Some things are not JSON, and need to be sent as bytes! pass request = Request(url, data, headers, method=method) if not nojson: if method is not "GET" and "Content-type" not in request.headers: request.add_header('Content-type', 'application/x-fs-v1+json') if 'Accept' not in request.headers: - request.add_header('Accept', 'application/json') + request.add_header('Accept', 'application/x-fs-v1+json;application/x-gedcomx-v1+json;application/x-gedcomx-atom+json;application/json') + if 'Accept-Charset' not in request.headers: + request.add_header('Accept-Charset', 'UTF-8') if self.logged_in and not self.cookies: # Add sessionId parameter to url if cookie is not set request.add_header('Authorization', 'Bearer ' + self.access_token) - request.add_header('User-Agent', self.agent) + request.add_header('User-Agent', self.user_agent) try: return self.opener.open(request) except HTTPError as error: - eh = dict(error.headers) + error_headers = dict(error.headers) if error.code == 401: self.logged_in = False if error.code == 429: - time.sleep(eh['Retry-after']/1000) + time.sleep(error_headers['Retry-after'] / 1000) return self._request(url, data, headers, method, nojson) raise @@ -177,8 +187,8 @@ def _add_subpath(self, url, subpath): For example, adding sub to http://example.com/path?query becomes http://example.com/path/sub?query. - """ + parts = urlsplit(url) path = parts[2] + '/' + subpath return urlunsplit((parts[0], parts[1], path, parts[3], parts[4])) @@ -187,6 +197,7 @@ def _add_query_params(self, url, params=None, **kw_params): """Add the specified query parameters to the given URL. Parameters can be passed either as a dictionary or as keyword arguments. """ + if params is None: params = {} parts = urlsplit(url) @@ -202,6 +213,7 @@ def _fs2py(self, response, nojson=False): Take JSON from FamilySearch response, and allow Python to handle it. Also, inject headers into response. """ + headers = dict(response.info()) response = response.read() response = response.decode("utf-8") @@ -218,25 +230,30 @@ def get(self, url, data=None, headers=None, nojson=False): def post(self, url, data=None, headers=None, nojson=False): """HTTP POST request""" + return self._fs2py(self._request( url, data, headers, "POST", nojson), nojson) def put(self, url, data=None, headers=None, nojson=False): """HTTP PUT request""" + return self._fs2py(self._request( url, data, headers, "PUT", nojson), nojson) def head(self, url, data=None, headers=None, nojson=False): """HTTP HEAD request""" + return self._fs2py(self._request( url, data, headers, "HEAD", nojson), nojson) def options(self, url, data=None, headers=None, nojson=False): """HTTP OPTIONS request""" + return self._fs2py(self._request( url, data, headers, "OPTIONS", nojson), nojson) def delete(self, url, data=None, headers=None, nojson=False): """HTTP DELETE request""" + return self._fs2py(self._request( url, data, headers, "DELETE", nojson), nojson) diff --git a/familysearch/authentication.py b/familysearch/authentication.py index 73c3198..dcbd35f 100644 --- a/familysearch/authentication.py +++ b/familysearch/authentication.py @@ -1,5 +1,9 @@ +# -*- coding: utf-8 -*- + """FamilySearch Authentication submodule""" + # Python imports +from __future__ import print_function try: # Python 3 @@ -18,33 +22,49 @@ # Magic + class Authentication(object): + def __init__(self): """https://familysearch.org/developers/docs/api/resources#authentication Set up the URLs for authentication. """ + self.token = self.root_collection['response']['collections'][0]['links']\ - ['http://oauth.net/core/2.0/endpoint/token']['href'] + ['http://oauth.net/core/2.0/endpoint/token']['href'] cookie_handler = HTTPCookieProcessor() self.cookies = cookie_handler.cookiejar self.opener = build_opener(cookie_handler) + self.logged_in = False + self.access_token = None def login(self, username, password): """Log into FamilySearch using Basic Authentication. This mechanism is available only to approved developer keys. """ + self.logged_in = False self.cookies.clear() url = self.token credentials = urlencode({'username': username, 'password': password, - 'client_id': self.key, + 'client_id': self.dev_key, 'grant_type': 'password' - }) + } + ) credentials = credentials.encode("utf-8") headers = {"Content-type": "application/x-www-form-urlencoded", - "Accept": "application/json"} + "Accept": "application/x-fs-v1+json;application/x-gedcomx-v1+json;application/x-gedcomx-atom+json;application/json", + "Accept-Charset": "utf-8"} + + def xprint(): + print("url:", url) + print("credentials:", credentials) + print("headers:", headers) + + xprint() response = self.post(url, credentials, headers) + print("authentication.login:response:", response) self.access_token = response['response']['access_token'] self.logged_in = True self.fix_discovery() @@ -52,22 +72,25 @@ def login(self, username, password): def oauth_desktop_login(self, ruri=None): """ Log into FamilySearch using OAuth2 Authentication. - This is primarily a convenience function for destop apps. + This is primarily a convenience function for desktop apps. Not normally intended for production apps, but should work while waiting for approval for password login. Default Redirect URI is "http://localhost:63342/fslogin", but you can set your own as a paramater. """ + if ruri is None: ruri = "http://localhost:63342/fslogin" self.logged_in = False self.cookies.clear() url = self.root_collection['response']['collections'][0]['links']\ - ['http://oauth.net/core/2.0/endpoint/authorize']['href'] - url = self._add_query_params(url, {'response_type': 'code', - 'client_id': self.key, - 'redirect_uri': ruri - }) + ['http://oauth.net/core/2.0/endpoint/authorize']['href'] + url = self._add_query_params(url, + {'response_type': 'code', + 'client_id': self.dev_key, + 'redirect_uri': ruri, + } + ) webbrowser.open(url) server.HTTPServer(('', 63342), Getter).handle_request() # Now that we have the authentication token, grab the access token. @@ -78,17 +101,26 @@ def oauth_code_login(self, code): Convenience function for Web servers to log into FamilySearch with the token code FamilySearch hands you. """ + url = self.token credentials = urlencode({'grant_type': 'authorization_code', 'code': code, - 'client_id': self.key - }) + 'client_id': self.dev_key, + } + ) credentials = credentials.encode("utf-8") headers = {"Content-type": "application/x-www-form-urlencoded", - "Accept": "application/json"} - response = self.post(url, credentials ,headers, nojson=True) - response = json.loads() - self.access_token = response['response']['access_token'] + "Accept": "application/x-fs-v1+json;application/x-gedcomx-v1+json;application/x-gedcomx-atom+json;application/json", + "Accept-Charset": "utf-8"} + response = self.post(url, credentials, headers, nojson=True) + def rprint(response): + import pprint + pp = pprint.PrettyPrinter(indent=2, width=120) + pp.pprint(response) + #rprint(response) + json_response = json.loads(response["response"]) + self.access_token = json_response['access_token'] + print("access_token:", self.access_token) self.logged_in = True self.fix_discovery() @@ -98,16 +130,18 @@ def unauthenticated_login(self, ip_address): Has very limited read-only access. Not intended for general use. """ + self.logged_in = False self.cookies.clear() url = self.token credentials = urlencode({'ip_address': ip_address, - #TODO: make IP address generiation automatic - 'client_id': self.key, + # TODO: make IP address generation automatic + 'client_id': self.dev_key, 'grant_type': 'unauthenticated_session' - }) + }) headers = {"Content-type": "application/x-www-form-urlencoded", - "Accept": "application/json"} + "Accept": "application/x-fs-v1+json;application/x-gedcomx-v1+json;application/x-gedcomx-atom+json;application/json", + "Accept-Charset": "utf-8"} credentials = credentials.encode("utf-8") response = self.post(url, credentials, headers) self.access_token = response['response']['access_token'] @@ -118,6 +152,7 @@ def logout(self): """ Log the current session out of FamilySearch. """ + self.logged_in = False url = self.token + "?access_token=" + self.access_token self.delete(url) @@ -125,12 +160,15 @@ def logout(self): self.cookies.clear() self.fix_discovery() + class Getter(server.BaseHTTPRequestHandler): """Sample login page, mostly for oauth_desktop_login.""" + def do_GET(self): """Sample page to get Oauth code, and log in with.""" + self.send_response(code=200) - self.send_header("Content-type", "text/html") + self.send_header("Content-type", "text/html;charset=utf-8") self.end_headers() path = self.path global qs @@ -141,4 +179,3 @@ def do_GET(self): sendme += "
You can safely close this page.