Skip to content

Commit 743c5d6

Browse files
committed
Merge branch 'devel'
2 parents 8127f7a + 59b99f4 commit 743c5d6

22 files changed

Lines changed: 521 additions & 277 deletions

README.rst

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ python-cozify
22
=============
33

44
Unofficial Python3 API bindings for the (unpublished) Cozify API.
5-
Includes 1:1 API calls plus helper functions to string together an
6-
authentication flow.
5+
Includes high-level helpers for easier use of the APIs,
6+
for example an automatic authentication flow, and low-level 1:1 API functions.
77

88
Installation
99
------------
@@ -89,18 +89,25 @@ And the expiry duration can be altered (also when calling cloud.ping()):
8989
Tests
9090
-----
9191
pytest is used for unit tests. Test coverage is still quite spotty and under active development.
92+
Certain tests are marked as "live" tests and require an active authentication state and a real hub to query against.
93+
Live tests are non-destructive.
9294

9395
During development you can run the test suite right from the source directory:
9496

9597
.. code:: bash
9698
97-
pytest -v
99+
pytest -v cozify/
100+
# or include the live tests as well:
101+
pytest -v cozify/ --live
98102
99103
To run the test suite on an already installed python-cozify:
100104

101105
.. code:: bash
102106
103107
pytest -v --pyargs cozify
108+
# or including live tests:
109+
pytest -v --pyargs cozify --live
110+
104111
105112
Current limitations
106113
-------------------

cozify/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.2.9.1"
1+
__version__ = "0.2.10"

cozify/cloud.py

Lines changed: 13 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,15 @@
1-
"""Module for handling Cozify Cloud API operations
2-
3-
Attributes:
4-
cloudBase(str): API endpoint including version
5-
1+
"""Module for handling Cozify Cloud highlevel operations.
62
"""
73

8-
import json, requests, logging, datetime
4+
import logging, datetime
95

106
from . import config as c
117
from . import hub
8+
from . import hub_api
9+
from . import cloud_api
1210

1311
from .Error import APIError, AuthenticationError
1412

15-
cloudBase='https://cloud2.cozify.fi/ui/0.2/'
16-
1713
def authenticate(trustCloud=True, trustHub=True, remote=False, autoremote=True):
1814
"""Authenticate with the Cozify Cloud and Hub.
1915
@@ -44,7 +40,7 @@ def authenticate(trustCloud=True, trustHub=True, remote=False, autoremote=True):
4440

4541
if _need_cloud_token(trustCloud):
4642
try:
47-
_requestlogin(email)
43+
cloud_api.requestlogin(email)
4844
except APIError:
4945
resetState() # a bogus email will shaft all future attempts, better to reset
5046
raise
@@ -57,7 +53,7 @@ def authenticate(trustCloud=True, trustHub=True, remote=False, autoremote=True):
5753
raise AuthenticationError(message)
5854

5955
try:
60-
cloud_token = _emaillogin(email, otp)
56+
cloud_token = cloud_api.emaillogin(email, otp)
6157
except APIError:
6258
logging.error('OTP authentication has failed.')
6359
resetState()
@@ -71,9 +67,9 @@ def authenticate(trustCloud=True, trustHub=True, remote=False, autoremote=True):
7167
cloud_token = _getAttr('remoteToken')
7268

7369
if _need_hub_token(trustHub):
74-
localHubs = _lan_ip() # will only work if we're local to the Hub, otherwise None
70+
localHubs = cloud_api.lan_ip() # will only work if we're local to the Hub, otherwise None
7571
# TODO(artanicus): unknown what will happen if there is a local hub but another one remote. Needs testing by someone with multiple hubs. Issue #7
76-
hubkeys = _hubkeys(cloud_token) # get all registered hubs and their keys from the cloud.
72+
hubkeys = cloud_api.hubkeys(cloud_token) # get all registered hubs and their keys from the cloud.
7773
if not hubkeys:
7874
logging.critical('You have not registered any hubs to the Cozify Cloud, hence a hub cannot be used yet.')
7975

@@ -87,18 +83,18 @@ def authenticate(trustCloud=True, trustHub=True, remote=False, autoremote=True):
8783
# if we're remote, we didn't get a valid ip
8884
if not localHubs:
8985
logging.info('No local Hubs detected, attempting authentication via Cozify Cloud.')
90-
hub_info = hub._hub(cloud_token=cloud_token, hub_token=hub_token)
86+
hub_info = hub_api.hub(remote=True, cloud_token=cloud_token, hub_token=hub_token)
9187
# if the hub wants autoremote we flip the state
9288
if hub.autoremote and not hub.remote:
9389
logging.info('[autoremote] Flipping hub remote status from local to remote.')
9490
hub.remote = True
9591
else:
9692
# localHubs is valid so a hub is in the lan. A mixed environment cannot yet be detected.
97-
# _lan_ip cannot provide a map as to which ip is which hub. Thus we actually need to determine the right one.
93+
# cloud_api.lan_ip cannot provide a map as to which ip is which hub. Thus we actually need to determine the right one.
9894
# TODO(artanicus): Need to truly test how multihub works before implementing ip to hub resolution. See issue #7
9995
logging.debug('data structure: {0}'.format(localHubs))
10096
hub_ip = localHubs[0]
101-
hub_info = hub._hub(host=hub_ip)
97+
hub_info = hub_api.hub(host=hub_ip, remote=False)
10298
# if the hub wants autoremote we flip the state
10399
if hub.autoremote and hub.remote:
104100
logging.info('[autoremote] Flipping hub remote status from remote to local.')
@@ -149,7 +145,7 @@ def ping(autorefresh=True, expiry=None):
149145
"""
150146

151147
try:
152-
_hubkeys(token()) # TODO(artanicus): see if there's a cheaper API call
148+
cloud_api.hubkeys(token()) # TODO(artanicus): see if there's a cheaper API call
153149
except APIError as e:
154150
if e.status_code == 401:
155151
return False
@@ -177,7 +173,7 @@ def refresh(force=False, expiry=datetime.timedelta(days=1)):
177173
"""
178174
if _need_refresh(force, expiry):
179175
try:
180-
cloud_token = _refreshsession(token())
176+
cloud_token = cloud_api.refreshsession(token())
181177
except APIError as e:
182178
if e.status_code == 401:
183179
# too late, our token is already dead
@@ -333,111 +329,3 @@ def email(new_email=None):
333329
if new_email:
334330
_setAttr('email', new_email)
335331
return _getAttr('email')
336-
337-
def _requestlogin(email):
338-
"""Raw Cloud API call, request OTP to be sent to account email address.
339-
340-
Args:
341-
email(str): Email address connected to Cozify account.
342-
"""
343-
344-
payload = { 'email': email }
345-
response = requests.post(cloudBase + 'user/requestlogin', params=payload)
346-
if response.status_code is not 200:
347-
raise APIError(response.status_code, response.text)
348-
349-
def _emaillogin(email, otp):
350-
"""Raw Cloud API call, request cloud token with email address & OTP.
351-
352-
Args:
353-
email(str): Email address connected to Cozify account.
354-
otp(int): One time passcode.
355-
356-
Returns:
357-
str: cloud token
358-
"""
359-
360-
payload = {
361-
'email': email,
362-
'password': otp
363-
}
364-
365-
response = requests.post(cloudBase + 'user/emaillogin', params=payload)
366-
if response.status_code == 200:
367-
return response.text
368-
else:
369-
raise APIError(response.status_code, response.text)
370-
371-
def _lan_ip():
372-
"""1:1 implementation of hub/lan_ip
373-
374-
This call will fail with an APIError if the requesting source address is not the same as that of the hub, i.e. if they're not in the same NAT network.
375-
The above is based on observation and may only be partially true.
376-
377-
Returns:
378-
list: List of Hub ip addresses.
379-
"""
380-
response = requests.get(cloudBase + 'hub/lan_ip')
381-
if response.status_code == 200:
382-
return json.loads(response.text)
383-
else:
384-
raise APIError(response.status_code, response.text)
385-
386-
def _hubkeys(cloud_token):
387-
"""1:1 implementation of user/hubkeys
388-
389-
Args:
390-
cloud_token(str) Cloud remote authentication token.
391-
392-
Returns:
393-
dict: Map of hub_id: hub_token pairs.
394-
"""
395-
headers = {
396-
'Authorization': cloud_token
397-
}
398-
response = requests.get(cloudBase + 'user/hubkeys', headers=headers)
399-
if response.status_code == 200:
400-
return json.loads(response.text)
401-
else:
402-
raise APIError(response.status_code, response.text)
403-
404-
def _refreshsession(cloud_token):
405-
"""1:1 implementation of user/refreshsession
406-
407-
Args:
408-
cloud_token(str) Cloud remote authentication token.
409-
410-
Returns:
411-
str: New cloud remote authentication token. Not automatically stored into state.
412-
"""
413-
headers = {
414-
'Authorization': cloud_token
415-
}
416-
response = requests.get(cloudBase + 'user/refreshsession', headers=headers)
417-
if response.status_code == 200:
418-
return response.text
419-
else:
420-
raise APIError(response.status_code, response.text)
421-
422-
def _remote(cloud_token, hub_token, apicall, put=False):
423-
"""1:1 implementation of 'hub/remote'
424-
425-
Args:
426-
cloud_token(str): Cloud remote authentication token.
427-
hub_token(str): Hub authentication token.
428-
apicall(str): Full API call that would normally go directly to hub, e.g. '/cc/1.6/hub/colors'
429-
430-
Returns:
431-
requests.response: Requests response object.
432-
"""
433-
434-
headers = {
435-
'Authorization': cloud_token,
436-
'X-Hub-Key': hub_token
437-
}
438-
if put:
439-
response = requests.put(cloudBase + 'hub/remote' + apicall, headers=headers)
440-
else:
441-
response = requests.get(cloudBase + 'hub/remote' + apicall, headers=headers)
442-
443-
return response

cozify/cloud_api.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""Module for handling Cozify Cloud API 1:1 functions
2+
3+
Attributes:
4+
cloudBase(str): API endpoint including version
5+
6+
"""
7+
8+
import json, requests
9+
10+
from .Error import APIError, AuthenticationError
11+
12+
cloudBase='https://cloud2.cozify.fi/ui/0.2/'
13+
14+
def requestlogin(email):
15+
"""Raw Cloud API call, request OTP to be sent to account email address.
16+
17+
Args:
18+
email(str): Email address connected to Cozify account.
19+
"""
20+
21+
payload = { 'email': email }
22+
response = requests.post(cloudBase + 'user/requestlogin', params=payload)
23+
if response.status_code is not 200:
24+
raise APIError(response.status_code, response.text)
25+
26+
def emaillogin(email, otp):
27+
"""Raw Cloud API call, request cloud token with email address & OTP.
28+
29+
Args:
30+
email(str): Email address connected to Cozify account.
31+
otp(int): One time passcode.
32+
33+
Returns:
34+
str: cloud token
35+
"""
36+
37+
payload = {
38+
'email': email,
39+
'password': otp
40+
}
41+
42+
response = requests.post(cloudBase + 'user/emaillogin', params=payload)
43+
if response.status_code == 200:
44+
return response.text
45+
else:
46+
raise APIError(response.status_code, response.text)
47+
48+
def lan_ip():
49+
"""1:1 implementation of hub/lan_ip
50+
51+
This call will fail with an APIError if the requesting source address is not the same as that of the hub, i.e. if they're not in the same NAT network.
52+
The above is based on observation and may only be partially true.
53+
54+
Returns:
55+
list: List of Hub ip addresses.
56+
"""
57+
response = requests.get(cloudBase + 'hub/lan_ip')
58+
if response.status_code == 200:
59+
return json.loads(response.text)
60+
else:
61+
raise APIError(response.status_code, response.text)
62+
63+
def hubkeys(cloud_token):
64+
"""1:1 implementation of user/hubkeys
65+
66+
Args:
67+
cloud_token(str) Cloud remote authentication token.
68+
69+
Returns:
70+
dict: Map of hub_id: hub_token pairs.
71+
"""
72+
headers = {
73+
'Authorization': cloud_token
74+
}
75+
response = requests.get(cloudBase + 'user/hubkeys', headers=headers)
76+
if response.status_code == 200:
77+
return json.loads(response.text)
78+
else:
79+
raise APIError(response.status_code, response.text)
80+
81+
def refreshsession(cloud_token):
82+
"""1:1 implementation of user/refreshsession
83+
84+
Args:
85+
cloud_token(str) Cloud remote authentication token.
86+
87+
Returns:
88+
str: New cloud remote authentication token. Not automatically stored into state.
89+
"""
90+
headers = {
91+
'Authorization': cloud_token
92+
}
93+
response = requests.get(cloudBase + 'user/refreshsession', headers=headers)
94+
if response.status_code == 200:
95+
return response.text
96+
else:
97+
raise APIError(response.status_code, response.text)
98+
99+
def remote(cloud_token, hub_token, apicall, put=False, payload=None, **kwargs):
100+
"""1:1 implementation of 'hub/remote'
101+
102+
Args:
103+
cloud_token(str): Cloud remote authentication token.
104+
hub_token(str): Hub authentication token.
105+
apicall(str): Full API call that would normally go directly to hub, e.g. '/cc/1.6/hub/colors'
106+
put(bool): Use PUT instead of GET.
107+
payload(str): json string to use as payload if put = True.
108+
109+
Returns:
110+
requests.response: Requests response object.
111+
"""
112+
113+
headers = {
114+
'Authorization': cloud_token,
115+
'X-Hub-Key': hub_token
116+
}
117+
if put:
118+
response = requests.put(cloudBase + 'hub/remote' + apicall, headers=headers, data=payload)
119+
else:
120+
response = requests.get(cloudBase + 'hub/remote' + apicall, headers=headers)
121+
122+
return response

cozify/conftest.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import pytest
2+
def pytest_addoption(parser):
3+
parser.addoption("--live", action="store_true",
4+
default=False, help="run tests requiring a functional auth and a real hub.")
5+
6+
def pytest_collection_modifyitems(config, items):
7+
if config.getoption("--live"):
8+
return
9+
skip_live = pytest.mark.skip(reason="need --live option to run")
10+
for item in items:
11+
if "live" in item.keywords:
12+
item.add_marker(skip_live)

0 commit comments

Comments
 (0)