├── MANIFEST.in ├── tests ├── __init__.py └── test_actigraph.py ├── actigraph ├── __init__.py └── client.py ├── tox.ini ├── LICENSE.txt ├── setup.py ├── README.md └── .gitignore /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | include README.md -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | __author__ = 'isparks' 3 | -------------------------------------------------------------------------------- /actigraph/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | __author__ = 'isparks' 3 | 4 | from actigraph.client import ActigraphClient 5 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = py27, py35 8 | 9 | [testenv] 10 | commands = {envpython} setup.py test 11 | deps = 12 | mock 13 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Medidata Solutions 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __author__ = 'iansparks' 3 | from setuptools import setup 4 | 5 | setup( 6 | name='actigraph', 7 | version='1.0.0', 8 | author='Ian Sparks', 9 | author_email='isparks@mdsol.com', 10 | packages=['actigraph'], 11 | url='https://github.com/mdsol/python-actigraph', 12 | license='MIT', 13 | description="A basic Actigraph client based on the requests library.", 14 | long_description=open('README.md').read(), 15 | zip_safe=False, 16 | include_package_data=True, 17 | test_suite='tests', 18 | package_data = { '': ['README.md'] }, 19 | install_requires=['requests', 'six'], 20 | classifiers=[ 21 | 'Development Status :: 5 - Production/Stable', 22 | 'Environment :: Web Environment', 23 | 'Intended Audience :: Developers', 24 | 'License :: OSI Approved :: MIT License', 25 | 'Operating System :: OS Independent', 26 | 'Programming Language :: Python', 27 | 'Programming Language :: Python :: 2.7', 28 | 'Programming Language :: Python :: 3.5', 29 | 'Topic :: Internet :: WWW/HTTP', 30 | 'Topic :: Software Development :: Libraries :: Python Modules', 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Actigraph client 2 | 3 | An Actigraph Authorizer for the [requests](http://docs.python-requests.org/en/latest/ "Requests") python HTTP library 4 | including a client that wraps the calls for the [Actigraph](http://www.actigraphcorp.com/ "Actigraph") Study and Subject API. 5 | 6 | ## Usage 7 | 8 | You will need a secret and access keypair provided by Actigraph. 9 | 10 | The ActigraphAuth authorized manages all the request signing and can be used with the requests library: 11 | 12 | >>> import requests 13 | >>> from actigraph import ActigraphAuth 14 | >>> auth = ActigraphAuth("https://studyadmin-api.actigraphcorp.com", "access_key", "secret_key") 15 | >>> requests.get("https://studyadmin-api.actigraphcorp.com/v1/studies", auth=auth, verify=False) 16 | 17 | 18 | Note that verify=False is required for SSL because the (valid) root cert used to sign the Actigraph certificate is not in 19 | requests' cache of root certs. 20 | 21 | You can also use the ActigraphClient class which is a wrapper around the Actigraph URL's: 22 | 23 | >>> from actigraph import ActigraphClient 24 | >>> ac = ActigraphClient("https://studyadmin-api.actigraphcorp.com", "access_key", "secret_key") 25 | >>> result = ac.get_all_studies() 26 | >>> result.json() 27 | [{u'DateCreated': u'2014-05-28T21:12:36Z', u'Id': 21, u'Name': u'Demo Study'}] 28 | >>> result.status_code 29 | 200 30 | >>> result.request.url 31 | https://studyadmin-api.actigraphcorp.com/v1/studies 32 | 33 | The return from all getXXX calls from the client is a [request](http://docs.python-requests.org/en/latest/ "Request") 34 | object. 35 | 36 | The Actigraph system returns results as JSON so the .json() method of the resulting request object is the most 37 | interesting. 38 | 39 | 40 | ## Installation 41 | 42 | Suggested: 43 | 44 | ``` 45 | bash 46 | $ pip install actigraph 47 | ``` 48 | 49 | or 50 | 51 | ```bash 52 | $ pip install git+git://github.com/mdsol/python-actigraph#egg=actigraph 53 | ``` 54 | 55 | ## Dependencies 56 | 57 | * requests 58 | 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### JetBrains template 3 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 4 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 5 | 6 | # User-specific stuff: 7 | .idea/workspace.xml 8 | .idea/tasks.xml 9 | .idea/dictionaries 10 | .idea/vcs.xml 11 | .idea/jsLibraryMappings.xml 12 | 13 | # Sensitive or high-churn files: 14 | .idea/dataSources.ids 15 | .idea/dataSources.xml 16 | .idea/dataSources.local.xml 17 | .idea/sqlDataSources.xml 18 | .idea/dynamic.xml 19 | .idea/uiDesigner.xml 20 | 21 | # Gradle: 22 | .idea/gradle.xml 23 | .idea/libraries 24 | 25 | # Mongo Explorer plugin: 26 | .idea/mongoSettings.xml 27 | 28 | ## File-based project format: 29 | *.iws 30 | 31 | ## Plugin-specific files: 32 | 33 | # IntelliJ 34 | /out/ 35 | 36 | # mpeltonen/sbt-idea plugin 37 | .idea_modules/ 38 | 39 | # JIRA plugin 40 | atlassian-ide-plugin.xml 41 | 42 | # Crashlytics plugin (for Android Studio and IntelliJ) 43 | com_crashlytics_export_strings.xml 44 | crashlytics.properties 45 | crashlytics-build.properties 46 | fabric.properties 47 | ### Python template 48 | # Byte-compiled / optimized / DLL files 49 | __pycache__/ 50 | *.py[cod] 51 | *$py.class 52 | 53 | # C extensions 54 | *.so 55 | 56 | # Distribution / packaging 57 | .Python 58 | env/ 59 | build/ 60 | develop-eggs/ 61 | dist/ 62 | downloads/ 63 | eggs/ 64 | .eggs/ 65 | lib/ 66 | lib64/ 67 | parts/ 68 | sdist/ 69 | var/ 70 | *.egg-info/ 71 | .installed.cfg 72 | *.egg 73 | 74 | # PyInstaller 75 | # Usually these files are written by a python script from a template 76 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 77 | *.manifest 78 | *.spec 79 | 80 | # Installer logs 81 | pip-log.txt 82 | pip-delete-this-directory.txt 83 | 84 | # Unit test / coverage reports 85 | htmlcov/ 86 | .tox/ 87 | .coverage 88 | .coverage.* 89 | .cache 90 | nosetests.xml 91 | coverage.xml 92 | *,cover 93 | .hypothesis/ 94 | 95 | # Translations 96 | *.mo 97 | *.pot 98 | 99 | # Django stuff: 100 | *.log 101 | local_settings.py 102 | 103 | # Flask stuff: 104 | instance/ 105 | .webassets-cache 106 | 107 | # Scrapy stuff: 108 | .scrapy 109 | 110 | # Sphinx documentation 111 | docs/_build/ 112 | 113 | # PyBuilder 114 | target/ 115 | 116 | # IPython Notebook 117 | .ipynb_checkpoints 118 | 119 | # pyenv 120 | .python-version 121 | 122 | # celery beat schedule file 123 | celerybeat-schedule 124 | 125 | # dotenv 126 | .env 127 | 128 | # virtualenv 129 | venv/ 130 | ENV/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | -------------------------------------------------------------------------------- /actigraph/client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | __author__ = 'isparks' 3 | 4 | import requests 5 | import datetime 6 | import hmac 7 | import hashlib 8 | import base64 9 | from six.moves.urllib.parse import urlencode 10 | 11 | SECONDS_IN_24_HOURS = 24 * 60 * 60 12 | 13 | def isodatetime(dt): 14 | """Takes a date, returns ISO8601 date/time format""" 15 | return dt.strftime('%Y-%m-%dT%H:%M:%S') 16 | 17 | def isodate(dt): 18 | """Takes a date, returns ISO8601 date format""" 19 | return dt.strftime('%Y-%m-%d') 20 | 21 | class ActigraphAuth(requests.auth.AuthBase): 22 | """Custom requests authorizer for Actigraph""" 23 | def __init__(self, base_url, access_key, secret_key): 24 | self.base_url = base_url 25 | self.access_key = access_key.encode('utf-8') 26 | self.secret_key = secret_key.encode('utf-8') 27 | 28 | def __call__(self, r): 29 | """Call is made like: 30 | requests.get(url, auth=MyAuth()) 31 | """ 32 | r.headers.update(self.make_headers(r.url)) 33 | return r 34 | 35 | def sign(self, signature_string): 36 | """Return the signed value of the signature string""" 37 | return base64.b64encode(hmac.new(self.secret_key, 38 | signature_string.encode('utf-8'), 39 | hashlib.sha256).digest()) 40 | 41 | def make_url(self, resource_url): 42 | """ 43 | Return full URL 44 | """ 45 | url = u"%s%s" % (self.base_url, resource_url,) 46 | return url 47 | 48 | def make_headers(self, url): 49 | """Make headers for the request.""" 50 | 51 | #Get the time of the request 52 | date_time = datetime.datetime.utcnow() 53 | 54 | #Make signature string 55 | signature_string = self.make_signature_string(url, date_time) 56 | 57 | #Sign it 58 | signed = self.sign(signature_string) 59 | 60 | #Set the headers 61 | headers = self.make_authentication_headers(signed, date_time) 62 | 63 | return headers 64 | 65 | def make_authentication_headers(self, signed_string, dt): 66 | """Makes headers for Authorization and date, including the access key and the string signed with 67 | the secret key 68 | 69 | Date must be in form '%a, %d %b %Y %H:%M:%S +0000' eg. Tue, 27 Mar 2007 19:36:42 +0000 or Actigraph will 70 | fail the request. 71 | 72 | """ 73 | return { 74 | 'Authorization' : u"AGS {}:{}".format(self.access_key.decode('utf-8'), 75 | signed_string.decode('utf-8')), 76 | 'Date' : dt.strftime('%a, %d %b %Y %H:%M:%S +0000') 77 | } 78 | 79 | def make_signature_string(self, url_path, dt): 80 | """Makes a signature string for signing of the form: 81 | 82 | https://github.com/actigraph/StudyAdminAPIDocumentation/blob/master/sections/authentication.md 83 | 84 | StringToSign = HTTP-Verb + "\n" + 85 | Content-MD5 + "\n" + 86 | Content-Type + "\n" + 87 | Date + "\n" + 88 | CanonicalizedResource; 89 | 90 | CanonicalizedResource = ; 91 | 92 | (Canonicalization doesn't mean anything, URI is passed as-is including query string, quirk of Actigraph docs) 93 | 94 | Date must be in ISO form. 95 | 96 | Returns the encoded string 97 | 98 | """ 99 | #API only has GET requests so there is no body to md5 hash, verb is always GET and content type is always '' 100 | vals = dict(body_md5 = '', 101 | verb='GET', 102 | url_path=url_path, 103 | date=isodatetime(dt), 104 | content_type='', 105 | ) 106 | 107 | string_to_sign = '{verb}\n{body_md5}\n{content_type}\n{date}Z\n{url_path}'.format(**vals) 108 | return string_to_sign 109 | 110 | 111 | class ActigraphClient(object): 112 | """A simple client that wraps the requests and authorization""" 113 | def __init__(self, base_url, access_key, secret_key): 114 | self.auth = ActigraphAuth(base_url, access_key, secret_key) 115 | 116 | def get(self, api_url): 117 | """Make a get request""" 118 | url = self.auth.make_url(api_url) 119 | #Verify = False because actigraph SSL cert signed by authority that is not in requests root cert store 120 | #TODO: Check that Verify still required 121 | return requests.get(url, auth=self.auth, verify=False) 122 | 123 | def _check_start_end(self, start, end): 124 | """Check start < end or raise ValueError""" 125 | if not start < end: 126 | raise ValueError("Start time after End Time") 127 | 128 | def _check_twenty_four_hours(self, start, end): 129 | """If difference between start and end > 24Hours raise ValueError""" 130 | # Cannot be greater than 24 hour gap 131 | diff = end - start 132 | if diff.total_seconds() > SECONDS_IN_24_HOURS: 133 | raise ValueError("Date span is greater than 24 hours") 134 | 135 | #- API Methods ----------------------------------------------------------------------------------------------------- 136 | 137 | def get_all_studies(self): 138 | """ 139 | Get all studies that these credentials can access 140 | 141 | https://github.com/actigraph/StudyAdminAPIDocumentation/blob/master/sections/studies.md#get-all-studies 142 | """ 143 | url = "/v1/studies" 144 | return self.get(url) 145 | 146 | def get_study(self, study_id): 147 | """ 148 | Get details of a particular study 149 | 150 | https://github.com/actigraph/StudyAdminAPIDocumentation/blob/master/sections/studies.md#get-a-study 151 | """ 152 | url = "/v1/studies/{0!s}".format(study_id) 153 | return self.get(url) 154 | 155 | def get_all_subjects(self, study_id): 156 | """ 157 | Get all subjects for a study 158 | 159 | https://github.com/actigraph/StudyAdminAPIDocumentation/blob/master/sections/studies.md#get-all-subjects-within-a-study 160 | """ 161 | url = "/v1/studies/{0!s}/subjects".format(study_id) 162 | return self.get(url) 163 | 164 | def get_subject(self, subject_id): 165 | """ 166 | Get Subject Details 167 | 168 | https://github.com/actigraph/StudyAdminAPIDocumentation/blob/master/sections/subjects.md#get-a-subject 169 | """ 170 | url = "/v1/subjects/{0!s}".format(subject_id) 171 | return self.get(url) 172 | 173 | 174 | def get_subject_stats(self, subject_id): 175 | """ 176 | Get Subject Statistics 177 | 178 | https://github.com/actigraph/StudyAdminAPIDocumentation/blob/master/sections/subjects.md#get-overall-stats-for-a-subject 179 | """ 180 | url = "/v1/subjects/{0!s}/stats".format(subject_id) 181 | return self.get(url) 182 | 183 | def get_subject_daily_stats(self, subject_id): 184 | """ 185 | Get Daily stats for a subject 186 | 187 | https://github.com/actigraph/StudyAdminAPIDocumentation/blob/master/sections/subjects.md#get-daily-stats-for-a-subject 188 | """ 189 | url = "/v1/subjects/{0!s}/daystats".format(subject_id) 190 | return self.get(url) 191 | 192 | def get_subject_daily_minutes(self, subject_id, date): 193 | """ 194 | Get daily minutes for subject 195 | 196 | https://github.com/actigraph/StudyAdminAPIDocumentation/blob/master/sections/subjects.md#get-daily-minutes-for-a-subject 197 | """ 198 | url = "/v1/subjects/{0!s}/dayminutes/{1}".format(subject_id, isodate(date)) 199 | return self.get(url) 200 | 201 | def get_subject_sleep_epochs(self, subject_id, inbed, outbed): 202 | """ 203 | Get Sleep Epochs for a subject 204 | https://github.com/actigraph/StudyAdminAPIDocumentation/blob/master/sections/subjects.md#get-sleep-epochs-for-a-subject-v11 205 | """ 206 | # Validations 207 | self._check_start_end(inbed, outbed) 208 | self._check_twenty_four_hours(inbed, outbed) 209 | 210 | url = "/v1/subjects/{0!s}/sleepepochs?inbed={1}&outbed={2}".format(subject_id, isodatetime(inbed), isodatetime(outbed)) 211 | return self.get(url) 212 | 213 | def get_subject_sleep_score(self, subject_id, inbed, outbed): 214 | """ 215 | Get Sleep Score for a subject 216 | 217 | https://github.com/actigraph/StudyAdminAPIDocumentation/blob/master/sections/subjects.md#get-sleep-score-for-a-subject-v11 218 | """ 219 | 220 | # Validations 221 | self._check_start_end(inbed, outbed) 222 | self._check_twenty_four_hours(inbed, outbed) 223 | 224 | url = "/v1/subjects/{0!s}/sleepscore?inbed={1}&outbed={2}".format(subject_id, isodatetime(inbed), isodatetime(outbed)) 225 | return self.get(url) 226 | 227 | def _mergeStartStopParams(self, url, start, stop): 228 | """Merge optional Start and Stop params into a URL string""" 229 | params = {} 230 | if start: 231 | params['start'] = isodatetime(start) 232 | if stop: 233 | params['stop'] = isodatetime(stop) 234 | 235 | if params: 236 | #Actigraph API does not like urlencoded : characters 237 | url = "{0}?{1}".format(url, urlencode(params)).replace('%3A',':') 238 | return url 239 | 240 | def get_subject_bout_periods(self, subject_id, start=None, stop=None): 241 | """ 242 | Get Subject Bout periods (when they are wearing and not wearing device) 243 | 244 | https://github.com/actigraph/StudyAdminAPIDocumentation/blob/master/sections/subjects.md#get-bout-periods-for-a-subject-v12 245 | """ 246 | url = "/v1/subjects/{0!s}/bouts".format(subject_id) 247 | 248 | if start and stop: 249 | self._check_start_end(start, stop) 250 | 251 | url = self._mergeStartStopParams(url, start, stop) 252 | 253 | return self.get(url) 254 | 255 | def get_subject_bed_times(self, subject_id, start=None, stop=None): 256 | """ 257 | Get Subject in and out of bed times 258 | 259 | https://github.com/actigraph/StudyAdminAPIDocumentation/blob/master/sections/subjects.md#get-bed-times-for-a-subject-v13 260 | """ 261 | url = "/v1/subjects/{0!s}/bedtimes".format(subject_id) 262 | 263 | if start and stop: 264 | self._check_start_end(start, stop) 265 | 266 | url = self._mergeStartStopParams(url, start, stop) 267 | 268 | return self.get(url) 269 | -------------------------------------------------------------------------------- /tests/test_actigraph.py: -------------------------------------------------------------------------------- 1 | __author__ = 'isparks' 2 | 3 | import unittest 4 | import datetime 5 | import requests 6 | import mock 7 | from six.moves.urllib.parse import urlparse, parse_qs 8 | 9 | from actigraph.client import ActigraphAuth, ActigraphClient, isodate, isodatetime 10 | 11 | 12 | EXAMPLE_ACCESS_KEY = u'testaccesskey' 13 | EXAMPLE_SECRET_KEY = u'testsecretkey' 14 | 15 | 16 | def assert_urls_equal(a, b): 17 | # Asserts two URLS match, working around the vagueries of query string ordering 18 | _a = urlparse(a) 19 | _b = urlparse(b) 20 | if _a.path == _b.path: 21 | if parse_qs(_a.query) == parse_qs(_b.query): 22 | return True 23 | return False 24 | 25 | class TestUtils(unittest.TestCase): 26 | """Test utility functions""" 27 | def test_date_formatting(self): 28 | dt = datetime.datetime(2014, 1, 9) 29 | self.assertEqual('2014-01-09', isodate(dt)) 30 | 31 | 32 | class TestRawAuthorizer(unittest.TestCase): 33 | """Test of making a call just with the authorizer and raw requests""" 34 | 35 | def test_raw_auth(self): 36 | auth = ActigraphAuth('http://example.com', EXAMPLE_SECRET_KEY, EXAMPLE_ACCESS_KEY) 37 | 38 | #Mock request 39 | class MockRequest(object): 40 | def __init__(self, url): 41 | self.headers = {} 42 | self.url = url 43 | 44 | result = auth(MockRequest('http://example.com/v1/studies')) 45 | 46 | self.assertTrue(True, 'Authorization' in result.headers) 47 | self.assertTrue(True, 'Date' in result.headers) 48 | 49 | class TestAuthClient(unittest.TestCase): 50 | """Test auth client functions""" 51 | def setUp(self): 52 | self.ac = ActigraphAuth('http://example.com',EXAMPLE_ACCESS_KEY,EXAMPLE_SECRET_KEY) 53 | 54 | def test_make_signature_string(self): 55 | """Test formatting of signature string""" 56 | dt = datetime.datetime.strptime('2007-03-27T19:36:42','%Y-%m-%dT%H:%M:%S') 57 | tested = self.ac.make_signature_string('https://studyadmin-api.actigraphcorp.com/v1/studies',dt) 58 | self.assertEqual("GET\n\n\n2007-03-27T19:36:42Z\nhttps://studyadmin-api.actigraphcorp.com/v1/studies", tested) 59 | 60 | def test_signature(self): 61 | """Test signature against example from Actigraph website""" 62 | #https://github.com/actigraph/StudyAdminAPIDocumentation/blob/master/sections/authentication.md#example-1 63 | tested = self.ac.sign("GET\n\n\n2014-06-19T15:14:31Z\nhttps://studyadmin-api.actigraphcorp.com/v1/studies") 64 | self.assertEqual("J+9FTQTAkfGmUsaRmB/HBMJOXG+4Xqbo3drXBVQwZ4o=", tested.decode('utf-8')) 65 | 66 | def test_make_headers(self): 67 | """Test formatting of headersg""" 68 | dt = datetime.datetime.strptime('2007-03-27T19:36:42','%Y-%m-%dT%H:%M:%S') 69 | 70 | headers = self.ac.make_authentication_headers('TEST_SIGNED_STRING'.encode('utf-8'), dt) 71 | 72 | self.assertEqual('Tue, 27 Mar 2007 19:36:42 +0000',headers['Date']) 73 | self.assertEqual(u'AGS %s:TEST_SIGNED_STRING' % self.ac.access_key.decode('utf-8'), headers['Authorization']) 74 | 75 | 76 | def test_make(self): 77 | """Test the _make method""" 78 | 79 | fixed_time = datetime.datetime(2014, 1, 1, 15, 33) 80 | 81 | #Patching datetime utcnow to return a fixed date so signature can be verified since date-based 82 | @mock.patch('datetime.datetime') 83 | def test_date(mock_dt): 84 | mock_dt.utcnow.return_value = fixed_time 85 | 86 | headers = self.ac.make_headers(self.ac.make_url("/v1/studies")) 87 | 88 | self.assertEqual(u'AGS testaccesskey:HiPTGTljix5BP+cTLwCGLA23pYL2E1jFDLzrVjuxUJE=', 89 | headers['Authorization']) 90 | 91 | #Run it 92 | test_date() 93 | 94 | 95 | class ACMockTests(unittest.TestCase): 96 | """ 97 | Base class for tests that exercise the ActigraphClient class 98 | """ 99 | def setUp(self): 100 | self.ac = ActigraphClient('http://example.com',EXAMPLE_ACCESS_KEY,EXAMPLE_SECRET_KEY) 101 | 102 | class TestClientValidations(ACMockTests): 103 | """ 104 | Tests that exercise the validations on parameters to functions 105 | """ 106 | def test_sleep_score_gt_24_hours(self): 107 | """Cannot request a > 24 hour span""" 108 | 109 | inbed = datetime.datetime(2014, 5, 29, 20, 0, 0) 110 | 111 | def do(): 112 | self.ac.get_subject_sleep_score(999, inbed, inbed + datetime.timedelta(hours=24, seconds=1) ) 113 | 114 | self.assertRaises(ValueError, do) 115 | 116 | def test_sleep_score_start_after_end(self): 117 | """Start must be before end""" 118 | inbed = datetime.datetime(2014, 5, 29, 20, 0, 0) 119 | def do(): 120 | self.ac.get_subject_sleep_score(999, inbed, inbed + datetime.timedelta(seconds=-1) ) 121 | self.assertRaises(ValueError, do) 122 | 123 | def test_sleep_epochs_gt_24_hours(self): 124 | """Cannot request a > 24 hour span""" 125 | 126 | inbed = datetime.datetime(2014, 5, 29, 20, 0, 0) 127 | 128 | def do(): 129 | self.ac.get_subject_sleep_epochs(999, inbed, inbed + datetime.timedelta(hours=24, seconds=1) ) 130 | 131 | self.assertRaises(ValueError, do) 132 | 133 | def test_sleep_epochs_start_after_end(self): 134 | """Start must be before end""" 135 | inbed = datetime.datetime(2014, 5, 29, 20, 0, 0) 136 | def do(): 137 | self.ac.get_subject_sleep_epochs(999, inbed, inbed + datetime.timedelta(seconds=-1) ) 138 | self.assertRaises(ValueError, do) 139 | 140 | def test_subject_bouts_start_after_end(self): 141 | """Start must be before end""" 142 | inbed = datetime.datetime(2014, 5, 29, 20, 0, 0) 143 | def do(): 144 | self.ac.get_subject_bout_periods(999, inbed, inbed + datetime.timedelta(seconds=-1) ) 145 | self.assertRaises(ValueError, do) 146 | 147 | class TestClientURLs(ACMockTests): 148 | """Gratuituous tests of URL building to get test coverage""" 149 | 150 | def test_get_all_studies(self): 151 | self.ac.get = mock.MagicMock('get') 152 | self.ac.get_all_studies() 153 | self.ac.get.assert_called_once_with('/v1/studies') 154 | 155 | def test_get_study(self): 156 | self.ac.get = mock.MagicMock('get') 157 | self.ac.get_study(123) 158 | self.ac.get.assert_called_once_with('/v1/studies/123') 159 | 160 | def test_get_all_subjects(self): 161 | self.ac.get = mock.MagicMock('get') 162 | self.ac.get_all_subjects(9) 163 | self.ac.get.assert_called_once_with('/v1/studies/9/subjects') 164 | 165 | def test_get_subject(self): 166 | self.ac.get = mock.MagicMock('get') 167 | self.ac.get_subject(123) 168 | self.ac.get.assert_called_once_with('/v1/subjects/123') 169 | 170 | def test_get_subject_stats(self): 171 | self.ac.get = mock.MagicMock('get') 172 | self.ac.get_subject_stats(123) 173 | self.ac.get.assert_called_once_with('/v1/subjects/123/stats') 174 | 175 | def test_get_subject_daily_stats(self): 176 | self.ac.get = mock.MagicMock('get') 177 | self.ac.get_subject_daily_stats(123) 178 | self.ac.get.assert_called_once_with('/v1/subjects/123/daystats') 179 | 180 | def test_getSubjectDailyMinutes(self): 181 | self.ac.get = mock.MagicMock('get') 182 | dt = datetime.datetime(2014, 6, 11) 183 | self.ac.get_subject_daily_minutes(123, dt) 184 | self.ac.get.assert_called_once_with('/v1/subjects/123/dayminutes/%s' % isodate(dt)) 185 | 186 | def test_subject_bouts_simple(self): 187 | """Check format of url with no parameters passed""" 188 | self.ac.get = mock.MagicMock('get') 189 | self.ac.get_subject_bout_periods(999) 190 | self.ac.get.assert_called_once_with('/v1/subjects/999/bouts') 191 | 192 | def test_subject_bouts_start_only(self): 193 | """Check format of url with start parameter passed""" 194 | self.ac.get = mock.MagicMock('get') 195 | self.ac.get_subject_bout_periods(999,start=datetime.datetime(2014, 5, 29, 20, 0, 0)) 196 | self.ac.get.assert_called_once_with('/v1/subjects/999/bouts?start=2014-05-29T20:00:00') 197 | 198 | def test_subject_bouts_stop_only(self): 199 | """Check format of url with end parameter passed""" 200 | self.ac.get = mock.MagicMock('get') 201 | self.ac.get_subject_bout_periods(999,stop=datetime.datetime(2014, 5, 30, 20, 0, 0)) 202 | self.ac.get.assert_called_once_with('/v1/subjects/999/bouts?stop=2014-05-30T20:00:00') 203 | 204 | def test_subject_bouts_both(self): 205 | """Check format of url with start and end parameter passed""" 206 | self.ac.get = mock.MagicMock('get') 207 | self.ac.get_subject_bout_periods(999,start=datetime.datetime(2014, 5, 29, 20, 0, 0),stop=datetime.datetime(2014, 5, 30, 20, 0, 0)) 208 | # this is slightly more difficult given how the ordering of the params can change 209 | self.assertEqual(1, self.ac.get.call_count) 210 | self.assertTrue(assert_urls_equal('/v1/subjects/999/bouts?start=2014-05-29T20:00:00&stop=2014-05-30T20:00:00', 211 | self.ac.get.call_args[0][0])) 212 | 213 | def test_subject_bed_times_simple(self): 214 | """Check format of url with no parameters passed""" 215 | self.ac.get = mock.MagicMock('get') 216 | self.ac.get_subject_bed_times(999) 217 | self.ac.get.assert_called_once_with('/v1/subjects/999/bedtimes') 218 | 219 | def test_subject_bed_times_start_only(self): 220 | """Check format of url with start parameter passed""" 221 | self.ac.get = mock.MagicMock('get') 222 | self.ac.get_subject_bed_times(999,start=datetime.datetime(2014, 5, 29, 20, 0, 0)) 223 | self.ac.get.assert_called_once_with('/v1/subjects/999/bedtimes?start=2014-05-29T20:00:00') 224 | 225 | def test_subject_bed_times_stop_only(self): 226 | """Check format of url with end parameter passed""" 227 | self.ac.get = mock.MagicMock('get') 228 | self.ac.get_subject_bed_times(999,stop=datetime.datetime(2014, 5, 30, 20, 0, 0)) 229 | self.ac.get.assert_called_once_with('/v1/subjects/999/bedtimes?stop=2014-05-30T20:00:00') 230 | 231 | def test_subject_bed_times_both(self): 232 | """Check format of url with start and end parameter passed""" 233 | self.ac.get = mock.MagicMock('get') 234 | self.ac.get_subject_bed_times(999, 235 | start=datetime.datetime(2014, 5, 29, 20, 0, 0), 236 | stop=datetime.datetime(2014, 5, 30, 20, 0, 0)) 237 | # this is slightly more difficult given how the ordering of the params can change 238 | self.assertEqual(1, self.ac.get.call_count) 239 | self.assertTrue( 240 | assert_urls_equal('/v1/subjects/999/bedtimes?start=2014-05-29T20:00:00&stop=2014-05-30T20:00:00', 241 | self.ac.get.call_args[0][0])) 242 | 243 | def test_subject_sleep_score(self): 244 | """Check format of url""" 245 | self.ac.get = mock.MagicMock('get') 246 | inbed = datetime.datetime(2014, 5, 29, 20, 0, 0) 247 | outbed = datetime.datetime(2014, 5, 30, 20, 0, 0) 248 | self.ac.get_subject_sleep_score(999,inbed, outbed) 249 | # this is slightly more difficult given how the ordering of the params can change 250 | self.assertEqual(1, self.ac.get.call_count) 251 | self.assertTrue( 252 | assert_urls_equal('/v1/subjects/999/sleepscore?inbed=%s&outbed=%s' % (isodatetime(inbed), 253 | isodatetime(outbed)), 254 | self.ac.get.call_args[0][0])) 255 | 256 | def test_subject_sleep_epochs(self): 257 | """Check format of url with start and end parameter passed""" 258 | self.ac.get = mock.MagicMock('get') 259 | inbed = datetime.datetime(2014, 5, 29, 20, 0, 0) 260 | outbed = datetime.datetime(2014, 5, 30, 20, 0, 0) 261 | self.ac.get_subject_sleep_epochs(999,inbed, outbed) 262 | # this is slightly more difficult given how the ordering of the params can change 263 | self.assertEqual(1, self.ac.get.call_count) 264 | self.assertTrue( 265 | assert_urls_equal('/v1/subjects/999/sleepepochs?inbed=%s&outbed=%s' % (isodatetime(inbed), 266 | isodatetime(outbed)), 267 | self.ac.get.call_args[0][0])) 268 | 269 | 270 | class TestPatchRequests(ACMockTests): 271 | """Gratuituous test patching requests.get to get to 100% coverage""" 272 | 273 | def test_getAllStudies(self): 274 | with mock.patch('actigraph.client.requests.get') as mocked: 275 | self.ac.get_all_studies() 276 | # Mocked call url we made is the first mocked call, second parameter, first element 277 | self.assertEqual('http://example.com/v1/studies',mocked.mock_calls[0][1][0]) 278 | 279 | 280 | if __name__ == '__main__': 281 | unittest.main() 282 | --------------------------------------------------------------------------------