From 25aa02fd3e8cad7ccc658012619d1d75b3aacb9b Mon Sep 17 00:00:00 2001 From: Olivier Mehani Date: Sun, 13 Aug 2023 22:55:32 +1000 Subject: [PATCH 01/19] Apply autopep linting Signed-off-by: Olivier Mehani --- AuroraPlus/__init__.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/AuroraPlus/__init__.py b/AuroraPlus/__init__.py index 9f038d6..48c3cda 100644 --- a/AuroraPlus/__init__.py +++ b/AuroraPlus/__init__.py @@ -3,42 +3,42 @@ from requests.adapters import HTTPAdapter from requests.exceptions import Timeout + class api: def __init__(self, username, password): self.Error = None self.token = None - self.url = 'https://api.auroraenergy.com.au/api' + self.url = 'https://api.auroraenergy.com.au/api' api_adapter = HTTPAdapter(max_retries=2) - + """Create a session and perform all requests in the same session""" session = requests.Session() session.mount(self.url, api_adapter) - session.headers.update({'Accept': 'application/json', 'User-Agent': 'AuroraPlus.py', 'Accept-Encoding' : 'gzip, deflate, br', 'Connection' : 'keep-alive' }) + session.headers.update({'Accept': 'application/json', 'User-Agent': 'AuroraPlus.py', 'Accept-Encoding': 'gzip, deflate, br', 'Connection': 'keep-alive'}) self.session = session """Try to get token, retry if failed""" self.gettoken(username, password) - - def gettoken(self, username, password): + def gettoken(self, username, password): """Get access token""" try: - token = self.session.post(self.url+'/identity/login',data={'username': username, 'password': password}, timeout=(6)) + token = self.session.post(self.url + '/identity/login', data={'username': username, 'password': password}, timeout=(6)) if (token.status_code == 200): tokenjson = token.json() self.token = tokenjson['accessToken'] """Get CustomerID and ServiceAgreementID""" - current = self.session.get(self.url+'/customers/current',headers={'Authorization': self.token}).json()[0] + current = self.session.get(self.url + '/customers/current', headers={'Authorization': self.token}).json()[0] self.customerId = current['CustomerID'] """Loop through premises to get active """ premises = current['Premises'] for premise in premises: if (premise['ServiceAgreementStatus'] == 'Active'): - self.Active = premise['ServiceAgreementStatus'] + self.Active = premise['ServiceAgreementStatus'] self.serviceAgreementID = premise['ServiceAgreementID'] if (self.Active != 'Active'): self.Error = 'No active premise found' @@ -80,7 +80,7 @@ def getyear(self): def getcurrent(self): try: """Request current customer data""" - current = self.session.get(self.url+'/customers/current',headers={'Authorization': self.token}) + current = self.session.get(self.url + '/customers/current', headers={'Authorization': self.token}) if (current.status_code == 200): currentjson = current.json()[0] @@ -105,4 +105,4 @@ def getcurrent(self): else: self.Error = 'Current request failed: ' + current.reason except Timeout: - self.Error = 'Current request timed out' \ No newline at end of file + self.Error = 'Current request timed out' From 10216852001002ccb633e5479ff126ebd1515198 Mon Sep 17 00:00:00 2001 From: Olivier Mehani Date: Sun, 13 Aug 2023 22:55:49 +1000 Subject: [PATCH 02/19] Add configurable index to historical requests Signed-off-by: Olivier Mehani --- AuroraPlus/__init__.py | 41 ++++++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/AuroraPlus/__init__.py b/AuroraPlus/__init__.py index 48c3cda..85d14ff 100644 --- a/AuroraPlus/__init__.py +++ b/AuroraPlus/__init__.py @@ -47,35 +47,46 @@ def gettoken(self, username, password): except Timeout: self.Error = 'Token request timed out' - def request(self, timespan): + def request(self, timespan, index=-1): try: - request = self.session.get(self.url + '/usage/' + timespan +'?serviceAgreementID=' + self.serviceAgreementID + '&customerId=' + self.customerId + '&index=-1', headers={'Authorization': self.token}) + request = self.session.get( + self.url + + '/usage/' + + timespan + + '?serviceAgreementID=' + + self.serviceAgreementID + + '&customerId=' + + self.customerId + + '&index=' + + str(index), + headers={'Authorization': self.token} + ) if (request.status_code == 200): return request.json() else: - self.Error = 'Data request failed: ' + request.reason + self.Error = 'Data request failed: ' + request.reason except Timeout: self.Error = 'Data request timed out' - def getsummary(self): - summarydata = self.request("day") + def getsummary(self, index=-1): + summarydata = self.request("day", index) self.DollarValueUsage = summarydata['SummaryTotals']['DollarValueUsage'] self.KilowattHourUsage = summarydata['SummaryTotals']['KilowattHourUsage'] - def getday(self): - self.day = self.request("day") + def getday(self, index=-1): + self.day = self.request("day", index) - def getweek(self): - self.week = self.request("week") + def getweek(self, index=-1): + self.week = self.request("week", index) - def getmonth(self): - self.month = self.request("month") + def getmonth(self, index=-1): + self.month = self.request("month", index) - def getquarter(self): - self.quarter = self.request("quarter") + def getquarter(self, index=-1): + self.quarter = self.request("quarter", index) - def getyear(self): - self.year = self.request("year") + def getyear(self, index=-1): + self.year = self.request("year", index) def getcurrent(self): try: From e2d75635b82303040f2b30477390f7d4ff077a6d Mon Sep 17 00:00:00 2001 From: Olivier Mehani Date: Sun, 13 Aug 2023 23:44:34 +1000 Subject: [PATCH 03/19] Fix description_file in setup.cfg Signed-off-by: Olivier Mehani --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 9d5f797..e0358b0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,3 @@ # Inside of setup.cfg [metadata] -description-file = README.md \ No newline at end of file +description_file = README.md From b35de50172b44521e8bfc985c9e07a5e1ce4d10a Mon Sep 17 00:00:00 2001 From: Olivier Mehani Date: Sun, 13 Aug 2023 23:46:46 +1000 Subject: [PATCH 04/19] Match directory case in setup.py Signed-off-by: Olivier Mehani --- {AuroraPlus => auroraplus}/__init__.py | 0 setup.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename {AuroraPlus => auroraplus}/__init__.py (100%) diff --git a/AuroraPlus/__init__.py b/auroraplus/__init__.py similarity index 100% rename from AuroraPlus/__init__.py rename to auroraplus/__init__.py diff --git a/setup.py b/setup.py index 0fee5aa..97f63cf 100644 --- a/setup.py +++ b/setup.py @@ -120,4 +120,4 @@ 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', ], -) \ No newline at end of file +) From 8f351c4c3a20626af2e3a295765c10014d9448d4 Mon Sep 17 00:00:00 2001 From: Olivier Mehani Date: Mon, 14 Aug 2023 00:01:41 +1000 Subject: [PATCH 05/19] Shim 1.5.0 gettoken behaviour Signed-off-by: Olivier Mehani --- auroraplus/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/auroraplus/__init__.py b/auroraplus/__init__.py index 85d14ff..804fb79 100644 --- a/auroraplus/__init__.py +++ b/auroraplus/__init__.py @@ -17,12 +17,18 @@ def __init__(self, username, password): session.mount(self.url, api_adapter) session.headers.update({'Accept': 'application/json', 'User-Agent': 'AuroraPlus.py', 'Accept-Encoding': 'gzip, deflate, br', 'Connection': 'keep-alive'}) self.session = session + self._username = username + self._password = password """Try to get token, retry if failed""" self.gettoken(username, password) - def gettoken(self, username, password): + def gettoken(self, username=None, password=None): """Get access token""" + if not username: + username = self._username + if not password: + password = self._password try: token = self.session.post(self.url + '/identity/login', data={'username': username, 'password': password}, timeout=(6)) From be00a978fb3be1428e63a56c2618ff1c732a57f4 Mon Sep 17 00:00:00 2001 From: Olivier Mehani Date: Thu, 23 Nov 2023 00:26:16 +1100 Subject: [PATCH 06/19] Authenticate using token rather that login/pw This is the new way, needed to support MFA Signed-off-by: Olivier Mehani --- auroraplus/__init__.py | 108 +++++++++++++++++++++++++---------------- setup.py | 55 ++++++++++----------- 2 files changed, 93 insertions(+), 70 deletions(-) diff --git a/auroraplus/__init__.py b/auroraplus/__init__.py index 804fb79..cac0722 100644 --- a/auroraplus/__init__.py +++ b/auroraplus/__init__.py @@ -3,60 +3,81 @@ from requests.adapters import HTTPAdapter from requests.exceptions import Timeout +from requests_oauthlib import OAuth2Session +from oauthlib.oauth2.rfc6749.errors import MissingTokenError + class api: + USER_AGENT = 'python/auroraplus' + + OAUTH_BASE_URL = 'https://customers.auroraenergy.com.au' \ + '/auroracustomers1p.onmicrosoft.com/b2c_1a_sign_in/' + AUTHORIZE_URL = OAUTH_BASE_URL + '/oauth2/v2.0/authorize' + TOKEN_URL = OAUTH_BASE_URL + '/oauth2/v2.0/token' + CLIENT_ID = '2ff9da64-8629-4a92-a4b6-850a3f02053d' + REDIRECT_URI = 'https://my.auroraenergy.com.au/login/redirect' + + API_URL = 'https://api.auroraenergy.com.au/api' + BEARER_TOKEN_URL = API_URL + '/identity/LoginToken' + + SCOPE = ['openid', 'profile', 'offline_access'] - def __init__(self, username, password): + def __init__(self, token=None): + """Initialise the API. + + Parameters: + ----------- + + token : dict + A pre-established token. Should contain at least an access_token and a token_type. + """ self.Error = None - self.token = None - self.url = 'https://api.auroraenergy.com.au/api' + self.token = token api_adapter = HTTPAdapter(max_retries=2) """Create a session and perform all requests in the same session""" - session = requests.Session() - session.mount(self.url, api_adapter) - session.headers.update({'Accept': 'application/json', 'User-Agent': 'AuroraPlus.py', 'Accept-Encoding': 'gzip, deflate, br', 'Connection': 'keep-alive'}) + session = OAuth2Session( + self.CLIENT_ID, + redirect_uri=self.REDIRECT_URI, + scope=self.SCOPE, + token=token + ) + session.mount(self.API_URL, api_adapter) + session.headers.update({ + 'Accept': 'application/json', + 'User-Agent': self.USER_AGENT, + 'Accept-Encoding': 'gzip, deflate, br', + 'Connection': 'keep-alive', + }) self.session = session - self._username = username - self._password = password - - """Try to get token, retry if failed""" - self.gettoken(username, password) - - def gettoken(self, username=None, password=None): - """Get access token""" - if not username: - username = self._username - if not password: - password = self._password - try: - token = self.session.post(self.url + '/identity/login', data={'username': username, 'password': password}, timeout=(6)) - if (token.status_code == 200): - tokenjson = token.json() - self.token = tokenjson['accessToken'] - - """Get CustomerID and ServiceAgreementID""" - current = self.session.get(self.url + '/customers/current', headers={'Authorization': self.token}).json()[0] - self.customerId = current['CustomerID'] - - """Loop through premises to get active """ - premises = current['Premises'] - for premise in premises: - if (premise['ServiceAgreementStatus'] == 'Active'): - self.Active = premise['ServiceAgreementStatus'] - self.serviceAgreementID = premise['ServiceAgreementID'] - if (self.Active != 'Active'): - self.Error = 'No active premise found' - else: - self.Error = 'Token request failed: ' + token.reason + def get_info(self): + """Get CustomerID and ServiceAgreementID""" + try: + r = self.session.get( + self.API_URL + '/customers/current' + ) + r.raise_for_status() + current = r.json()[0] + self.customerId = current['CustomerID'] + + """Loop through premises to get active """ + premises = current['Premises'] + for premise in premises: + if (premise['ServiceAgreementStatus'] == 'Active'): + self.Active = premise['ServiceAgreementStatus'] + self.serviceAgreementID = premise['ServiceAgreementID'] + if (self.Active != 'Active'): + self.Error = 'No active premise found' except Timeout: - self.Error = 'Token request timed out' + self.Error = 'Info request timed out' def request(self, timespan, index=-1): + if not self.serviceAgreementID: + self.get_info() try: request = self.session.get( - self.url + self.API_URL + '/usage/' + timespan + '?serviceAgreementID=' @@ -64,8 +85,7 @@ def request(self, timespan, index=-1): + '&customerId=' + self.customerId + '&index=' - + str(index), - headers={'Authorization': self.token} + + str(index) ) if (request.status_code == 200): return request.json() @@ -97,7 +117,9 @@ def getyear(self, index=-1): def getcurrent(self): try: """Request current customer data""" - current = self.session.get(self.url + '/customers/current', headers={'Authorization': self.token}) + current = self.session.get( + self.API_URL + '/customers/current' + ) if (current.status_code == 200): currentjson = current.json()[0] diff --git a/setup.py b/setup.py index 97f63cf..f464038 100644 --- a/setup.py +++ b/setup.py @@ -3,12 +3,12 @@ from os import path setup( - name = 'auroraplus', - packages = ['auroraplus'], - version = '1.1.6', - license='MIT', - description = 'Python library to access the Aurora+ API: https://api.auroraenergy.com.au/api', - long_description="""AuroraPlus is a package to pull data from https://api.auroraenergy.com.au/api. To use the Aurora+ API you need a valid account with Aurora. + name='auroraplus', + packages=['auroraplus'], + version='1.1.6', + license='MIT', + description='Python library to access the Aurora+ API: https://api.auroraenergy.com.au/api', + long_description="""AuroraPlus is a package to pull data from https://api.auroraenergy.com.au/api. To use the Aurora+ API you need a valid account with Aurora. ## Requirements - Install Python 3.9 (for all users) @@ -19,7 +19,7 @@ Connect to Aurora+ API: import auroraplus - AuroraPlus = auroraplus.api("user.name@outlook.com", "password") + AuroraPlus = auroraplus.api(token) To get current account information use the following: @@ -42,7 +42,7 @@ An example getting specific data with getcurrent: import auroraplus - AuroraPlus = auroraplus.api("user.name@outlook.com", "password") + AuroraPlus = auroraplus.api({"access_token": "...", "token_type": "bearer"}) if (not AuroraPlus.Error): AuroraPlus.getcurrent() print(AuroraPlus.AmountOwed) @@ -101,23 +101,24 @@ print(AuroraPlus.year) else: print(AuroraPlus.Error)""", - long_description_content_type='text/markdown', - author = 'Leigh Curran', - author_email = 'AuroraPlusPy@outlook.com', - url = 'https://github.com/leighcurran/AuroraPlus', - keywords = ['Aurora+', 'AuroraPlus', 'Aurora', 'Tasmania', 'API'], - install_requires=[ - 'requests', - ], - classifiers=[ - 'Development Status :: 3 - Alpha', # Chose either "3 - Alpha", "4 - Beta" or "5 - Production/Stable" as the current state of your package - 'Intended Audience :: Developers', - 'Topic :: Software Development :: Build Tools', - 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - ], + long_description_content_type='text/markdown', + author='Leigh Curran', + author_email='AuroraPlusPy@outlook.com', + url='https://github.com/leighcurran/AuroraPlus', + keywords=['Aurora+', 'AuroraPlus', 'Aurora', 'Tasmania', 'API'], + install_requires=[ + 'requests', + 'requests_oauthlib', + ], + classifiers=[ + 'Development Status :: 3 - Alpha', # Chose either "3 - Alpha", "4 - Beta" or "5 - Production/Stable" as the current state of your package + 'Intended Audience :: Developers', + 'Topic :: Software Development :: Build Tools', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + ], ) From d3ce26439d2657772cbcddc0d93164cbceb7afb1 Mon Sep 17 00:00:00 2001 From: Olivier Mehani Date: Thu, 23 Nov 2023 00:37:42 +1100 Subject: [PATCH 07/19] fixup! Authenticate using token rather that login/pw --- readme.md | 12 ++++++++---- setup.cfg | 2 +- setup.py | 8 ++++++-- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/readme.md b/readme.md index 9802551..7031a28 100644 --- a/readme.md +++ b/readme.md @@ -11,7 +11,8 @@ AuroraPlus is a package to pull data from https://api.auroraenergy.com.au/api. T Connect to Aurora+ API: import auroraplus - AuroraPlus = auroraplus.api("user.name@outlook.com", "password") + AuroraPlus = auroraplus.api({"access_token": "...", "token_type": "bearer"}) + AuroraPlus.get_info() To get current account information use the following: @@ -34,7 +35,8 @@ getcurrent() gets the following data: An example getting specific data with getcurrent: import auroraplus - AuroraPlus = auroraplus.api("user.name@outlook.com", "password") + AuroraPlus = auroraplus.api({"access_token": "...", "token_type": "bearer"}) + AuroraPlus.get_info() if (not AuroraPlus.Error): AuroraPlus.getcurrent() print(AuroraPlus.AmountOwed) @@ -50,7 +52,8 @@ To get summary usage information use the following: An example getting specific data with getsummary: import auroraplus - AuroraPlus = auroraplus.api("user.name@outlook.com", "password") + AuroraPlus = auroraplus.api({"access_token": "...", "token_type": "bearer"}) + AuroraPlus.get_info() if (not AuroraPlus.Error): AuroraPlus.getsummary() print(AuroraPlus.DollarValueUsage['T41']) @@ -75,7 +78,8 @@ To get usage data use the following, this returns all available data in json for Full example: - AuroraPlus = auroraplus.api("user.name@outlook.com", "password") + AuroraPlus = auroraplus.api({"access_token": "...", "token_type": "bearer"}) + AuroraPlus.get_info() if (not AuroraPlus.Error): AuroraPlus.getcurrent() print(AuroraPlus.AmountOwed) diff --git a/setup.cfg b/setup.cfg index e0358b0..e2a7a51 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,3 @@ # Inside of setup.cfg [metadata] -description_file = README.md +description_file = readme.md diff --git a/setup.py b/setup.py index f464038..a25ee99 100644 --- a/setup.py +++ b/setup.py @@ -20,6 +20,7 @@ import auroraplus AuroraPlus = auroraplus.api(token) + AuroraPlus.get_info() To get current account information use the following: @@ -43,6 +44,7 @@ import auroraplus AuroraPlus = auroraplus.api({"access_token": "...", "token_type": "bearer"}) + AuroraPlus.get_info() if (not AuroraPlus.Error): AuroraPlus.getcurrent() print(AuroraPlus.AmountOwed) @@ -58,7 +60,8 @@ An example getting specific data with getsummary: import auroraplus - AuroraPlus = auroraplus.api("user.name@outlook.com", "password") + AuroraPlus = auroraplus.api({"access_token": "...", "token_type": "bearer"}) + AuroraPlus.get_info() if (not AuroraPlus.Error): AuroraPlus.getsummary() print(AuroraPlus.DollarValueUsage['T41']) @@ -83,7 +86,8 @@ Full example: - AuroraPlus = auroraplus.api("user.name@outlook.com", "password") + AuroraPlus = auroraplus.api({"access_token": "...", "token_type": "bearer"}) + AuroraPlus.get_info() if (not AuroraPlus.Error): AuroraPlus.getcurrent() print(AuroraPlus.AmountOwed) From 2514dab29f056eed7dd4ddb97d84159b95bd142a Mon Sep 17 00:00:00 2001 From: Olivier Mehani Date: Thu, 23 Nov 2023 23:55:14 +1100 Subject: [PATCH 08/19] fixup! fixup! Authenticate using token rather that login/pw --- auroraplus/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/auroraplus/__init__.py b/auroraplus/__init__.py index cac0722..961b0b9 100644 --- a/auroraplus/__init__.py +++ b/auroraplus/__init__.py @@ -4,7 +4,6 @@ from requests.exceptions import Timeout from requests_oauthlib import OAuth2Session -from oauthlib.oauth2.rfc6749.errors import MissingTokenError class api: From 816d064c9a161bd04dec9a9b5b42497232d382b2 Mon Sep 17 00:00:00 2001 From: Olivier Mehani Date: Thu, 23 Nov 2023 23:55:37 +1100 Subject: [PATCH 09/19] squash! fixup! fixup! Authenticate using token rather that login/pw Maintain backward compatibility --- auroraplus/__init__.py | 33 ++++++++++++++++++++++++++++++--- readme.md | 8 ++++---- setup.py | 6 +++--- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/auroraplus/__init__.py b/auroraplus/__init__.py index 961b0b9..7a58ea8 100644 --- a/auroraplus/__init__.py +++ b/auroraplus/__init__.py @@ -21,16 +21,34 @@ class api: SCOPE = ['openid', 'profile', 'offline_access'] - def __init__(self, token=None): - """Initialise the API. + def __init__(self, + username: str = None, password: str = None, + token: dict = None): + """Initialise the API. Parameters: ----------- + username: str + Deprecated, kept for backward compatibility + + password: str + Deprecated, kept for backward compatibility. If passed with an empty + username and no token, the password will be use as a bearer + access_token. + token : dict - A pre-established token. Should contain at least an access_token and a token_type. + A pre-established token. It should contain at least an access_token + and a token_type. + """ self.Error = None + backward_compat = False + if not username and not token: + # Backward compatibility: if no username and no token, + # assume the passowrd is a bearer access token + token = {'access_token': password, 'token_type': 'bearer'} + backward_compat = True self.token = token api_adapter = HTTPAdapter(max_retries=2) @@ -50,6 +68,15 @@ def __init__(self, token=None): }) self.session = session + if backward_compat: + self.get_info() + + def get_token(self, username=None, password=None): + """ + Deprecated, kept for backward compatibility + """ + self.get_info() + def get_info(self): """Get CustomerID and ServiceAgreementID""" try: diff --git a/readme.md b/readme.md index 7031a28..8553131 100644 --- a/readme.md +++ b/readme.md @@ -11,7 +11,7 @@ AuroraPlus is a package to pull data from https://api.auroraenergy.com.au/api. T Connect to Aurora+ API: import auroraplus - AuroraPlus = auroraplus.api({"access_token": "...", "token_type": "bearer"}) + AuroraPlus = auroraplus.api(token={"access_token": "...", "token_type": "bearer"}) AuroraPlus.get_info() To get current account information use the following: @@ -35,7 +35,7 @@ getcurrent() gets the following data: An example getting specific data with getcurrent: import auroraplus - AuroraPlus = auroraplus.api({"access_token": "...", "token_type": "bearer"}) + AuroraPlus = auroraplus.api(token={"access_token": "...", "token_type": "bearer"}) AuroraPlus.get_info() if (not AuroraPlus.Error): AuroraPlus.getcurrent() @@ -52,7 +52,7 @@ To get summary usage information use the following: An example getting specific data with getsummary: import auroraplus - AuroraPlus = auroraplus.api({"access_token": "...", "token_type": "bearer"}) + AuroraPlus = auroraplus.api(token={"access_token": "...", "token_type": "bearer"}) AuroraPlus.get_info() if (not AuroraPlus.Error): AuroraPlus.getsummary() @@ -78,7 +78,7 @@ To get usage data use the following, this returns all available data in json for Full example: - AuroraPlus = auroraplus.api({"access_token": "...", "token_type": "bearer"}) + AuroraPlus = auroraplus.api(token={"access_token": "...", "token_type": "bearer"}) AuroraPlus.get_info() if (not AuroraPlus.Error): AuroraPlus.getcurrent() diff --git a/setup.py b/setup.py index a25ee99..6940eaf 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,7 @@ An example getting specific data with getcurrent: import auroraplus - AuroraPlus = auroraplus.api({"access_token": "...", "token_type": "bearer"}) + AuroraPlus = auroraplus.api(token={"access_token": "...", "token_type": "bearer"}) AuroraPlus.get_info() if (not AuroraPlus.Error): AuroraPlus.getcurrent() @@ -60,7 +60,7 @@ An example getting specific data with getsummary: import auroraplus - AuroraPlus = auroraplus.api({"access_token": "...", "token_type": "bearer"}) + AuroraPlus = auroraplus.api(token={"access_token": "...", "token_type": "bearer"}) AuroraPlus.get_info() if (not AuroraPlus.Error): AuroraPlus.getsummary() @@ -86,7 +86,7 @@ Full example: - AuroraPlus = auroraplus.api({"access_token": "...", "token_type": "bearer"}) + AuroraPlus = auroraplus.api(token={"access_token": "...", "token_type": "bearer"}) AuroraPlus.get_info() if (not AuroraPlus.Error): AuroraPlus.getcurrent() From cfff3b5603adddb2ce84ee7e5ae9a170fc031ad6 Mon Sep 17 00:00:00 2001 From: Olivier Mehani Date: Fri, 24 Nov 2023 00:38:21 +1100 Subject: [PATCH 10/19] Automatically extract long_description from readme Signed-off-by: Olivier Mehani --- setup.py | 102 +++---------------------------------------------------- 1 file changed, 5 insertions(+), 97 deletions(-) diff --git a/setup.py b/setup.py index 6940eaf..b2c00dd 100644 --- a/setup.py +++ b/setup.py @@ -2,109 +2,17 @@ from distutils.core import setup from os import path +from pathlib import Path +this_directory = Path(__file__).parent +long_description = (this_directory / "readme.md").read_text() + setup( name='auroraplus', packages=['auroraplus'], version='1.1.6', license='MIT', description='Python library to access the Aurora+ API: https://api.auroraenergy.com.au/api', - long_description="""AuroraPlus is a package to pull data from https://api.auroraenergy.com.au/api. To use the Aurora+ API you need a valid account with Aurora. - -## Requirements -- Install Python 3.9 (for all users) -- Pip install requests - -## Usage - -Connect to Aurora+ API: - - import auroraplus - AuroraPlus = auroraplus.api(token) - AuroraPlus.get_info() - -To get current account information use the following: - - AuroraPlus.getcurrent() - -getcurrent() gets the following data: - - EstimatedBalance - This is shown in the Aurora+ app as 'Balance' - UsageDaysRemaining - This is shown in the Aurora+ app as 'Days Prepaid' - AverageDailyUsage - AmountOwed - ActualBalance - UnbilledAmount - BillTotalAmount - NumberOfUnpaidBills - BillOverDueAmount - - Note: All data except AverageDailyUsage is updated Daily. - -An example getting specific data with getcurrent: - - import auroraplus - AuroraPlus = auroraplus.api(token={"access_token": "...", "token_type": "bearer"}) - AuroraPlus.get_info() - if (not AuroraPlus.Error): - AuroraPlus.getcurrent() - print(AuroraPlus.AmountOwed) - else: - print(AuroraPlus.Error) - -To get summary usage information use the following: - - AuroraPlus.getsummary() - - Note: This returns two collections, DollarValueUsage and KilowattHourUsage. - -An example getting specific data with getsummary: - - import auroraplus - AuroraPlus = auroraplus.api(token={"access_token": "...", "token_type": "bearer"}) - AuroraPlus.get_info() - if (not AuroraPlus.Error): - AuroraPlus.getsummary() - print(AuroraPlus.DollarValueUsage['T41']) - print(AuroraPlus.DollarValueUsage['T31']) - print(AuroraPlus.DollarValueUsage['Other']) - print(AuroraPlus.DollarValueUsage['Total']) - print(AuroraPlus.KilowattHourUsage['T41']) - print(AuroraPlus.KilowattHourUsage['T31']) - print(AuroraPlus.KilowattHourUsage['Total']) - else: - print(AuroraPlus.Error) - - Note: Offpeak tarrifs not listed - -To get usage data use the following, this returns all available data in json format for each timespan: - - AuroraPlus.getday() - AuroraPlus.getweek() - AuroraPlus.getmonth() - AuroraPlus.getquarter() - AuroraPlus.getyear() - -Full example: - - AuroraPlus = auroraplus.api(token={"access_token": "...", "token_type": "bearer"}) - AuroraPlus.get_info() - if (not AuroraPlus.Error): - AuroraPlus.getcurrent() - print(AuroraPlus.AmountOwed) - - AuroraPlus.getday() - print(AuroraPlus.day) - - AuroraPlus.getweek() - print(AuroraPlus.week) - - AuroraPlus.getmonth() - print(AuroraPlus.month - - AuroraPlus.getyear() - print(AuroraPlus.year) - else: - print(AuroraPlus.Error)""", + long_description=long_description, long_description_content_type='text/markdown', author='Leigh Curran', author_email='AuroraPlusPy@outlook.com', From 04388a77a41ab6475d05675a745a771793fd790a Mon Sep 17 00:00:00 2001 From: Olivier Mehani Date: Fri, 24 Nov 2023 00:38:42 +1100 Subject: [PATCH 11/19] Add methods to issue a new OAuth token Signed-off-by: Olivier Mehani --- auroraplus/__init__.py | 104 +++++++++++++++++++++++++++++++++++++++-- readme.md | 31 ++++++++++-- 2 files changed, 127 insertions(+), 8 deletions(-) diff --git a/auroraplus/__init__.py b/auroraplus/__init__.py index 7a58ea8..079ecec 100644 --- a/auroraplus/__init__.py +++ b/auroraplus/__init__.py @@ -1,8 +1,13 @@ """Abstraction of the Aurora+ API""" -import requests +import base64 +import hashlib +import json +import random +import string +import uuid + from requests.adapters import HTTPAdapter from requests.exceptions import Timeout - from requests_oauthlib import OAuth2Session @@ -44,7 +49,7 @@ def __init__(self, """ self.Error = None backward_compat = False - if not username and not token: + if not token and password and not username: # Backward compatibility: if no username and no token, # assume the passowrd is a bearer access token token = {'access_token': password, 'token_type': 'bearer'} @@ -68,9 +73,100 @@ def __init__(self, }) self.session = session - if backward_compat: + if backward_compat and self.token: self.get_info() + def oauth_authorize(self) -> str: + """ + Start an OAuth Web Application authentication with Aurora Plus + + Returns: + -------- + + str: the URL to query interactively to authorize this session + + """ + state = { + "id": str(uuid.uuid4()), + "meta": {"interactionType": "redirect"}, + } + + self.code_verifier = ''.join([random.choice( + string.ascii_letters + + string.digits + + '-_') + for i in range(43)]) + code_challenge = base64.urlsafe_b64encode( + hashlib.sha256(self.code_verifier.encode()).digest() + ).strip(b'=') + + authorization_url, _ = self.session.authorization_url( + self.AUTHORIZE_URL, + client_request_id=uuid.uuid4(), + client_info=1, + code_challenge=code_challenge, + code_challenge_method='S256', + state=base64.encodebytes(json.dumps(state).encode())) + + return authorization_url + + def oauth_redirect(self, authorization_response: str): + """ + Continue an OAuth Web Application authentication with Aurora Plus. + + Needs to be called after oauth_authorize. + + Parameters: + ----------- + + authorization_response: str + + The full URI of the response (error) page after authentication. + + Returns: + -------- + + dict: full token information + + """ + if not self.session.compliance_hook['access_token_response']: + self.session.register_compliance_hook( + 'access_token_response', + self._include_access_token) + + return self.session.fetch_token( + self.TOKEN_URL, + authorization_response=authorization_response, + code_verifier=self.code_verifier, + ) + + def _include_access_token(self, r) -> dict: + """ + OAuth compliance hook to fetch the bespoke LoginToken, + and present it as a standard access_token. + + Returns: + -------- + + dict: the full token + """ + rjs = r.json() + id_token = rjs.get('id_token') + + atr = self.session.post(self.BEARER_TOKEN_URL, + json={'token': id_token} + ) + access_token = atr.json().get('accessToken') + + rjs.update({ + 'access_token': access_token.split()[1], + 'scope': 'openid profile offline_access', + }) + + r._content = json.dumps(rjs).encode() + + return r + def get_token(self, username=None, password=None): """ Deprecated, kept for backward compatibility diff --git a/readme.md b/readme.md index 8553131..4a9d665 100644 --- a/readme.md +++ b/readme.md @@ -8,13 +8,34 @@ AuroraPlus is a package to pull data from https://api.auroraenergy.com.au/api. T ## Usage -Connect to Aurora+ API: +### Obtain a token + +Obtaining a token is an interactive process where the user needs to log on to +the AuroraPlus web application. + + >>> import auroraplus + >>> api = auroraplus.api() + >>> api.oauth_authorize() + 'https://customers.auroraenergy.com.au/auroracustomers1p.onmicrosoft.com/b2c_1a_sign_in//oauth2/v2.0/authorize?response_type=code&client_id=2ff9da64-8629-4a92-a4b6-850a3f02053d&redirect_uri=https%3A%2F%2Fmy.auroraenergy.com.au%2Flogin%2Fredirect&scope=openid+profile+offline_access&state=...&client_info=1' + +Follow the URL above in a browser to authenticate with username+password and +MFA. This will redirect to an error page (Cradle Mountain). Copy the full URL, +of the error page and continue. + + >>> api.oauth_redirect('https://my.auroraenergy.com.au/login/redirect?state=...') + {'id_token': 'ey...', 'access_token': 'ey...', 'token_type': 'bearer', ...} + +The `api` object is now ready to use. The token returned in the last step can be +saved for later use when re-initialising the api object without having to follow +the OAuth flow to obtain a new authorisation. + +### Connect to Aurora+ API with a pre-issued token import auroraplus AuroraPlus = auroraplus.api(token={"access_token": "...", "token_type": "bearer"}) AuroraPlus.get_info() -To get current account information use the following: +### Get current account information AuroraPlus.getcurrent() @@ -43,7 +64,7 @@ An example getting specific data with getcurrent: else: print(AuroraPlus.Error) -To get summary usage information use the following: +### Get summary usage information AuroraPlus.getsummary() @@ -68,7 +89,9 @@ An example getting specific data with getsummary: Note: Offpeak tarrifs not listed -To get usage data use the following, this returns all available data in json format for each timespan: +### Get usage data use the following + +The following returns all available data in json format for each timespan: AuroraPlus.getday() AuroraPlus.getweek() From f9a80e4488b8f43307770d6103b3479218bfb943 Mon Sep 17 00:00:00 2001 From: Olivier Mehani Date: Fri, 24 Nov 2023 00:46:55 +1100 Subject: [PATCH 12/19] fixup! Add methods to issue a new OAuth token --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 4a9d665..d4b05fa 100644 --- a/readme.md +++ b/readme.md @@ -26,7 +26,7 @@ of the error page and continue. {'id_token': 'ey...', 'access_token': 'ey...', 'token_type': 'bearer', ...} The `api` object is now ready to use. The token returned in the last step can be -saved for later use when re-initialising the api object without having to follow +saved for later use when re-initialising the `api` object without having to follow the OAuth flow to obtain a new authorisation. ### Connect to Aurora+ API with a pre-issued token From 30276a01bd6b9bcbded124c6326493363794f9ee Mon Sep 17 00:00:00 2001 From: Olivier Mehani Date: Wed, 29 Nov 2023 00:15:35 +1100 Subject: [PATCH 13/19] fixup! squash! fixup! fixup! Authenticate using token rather that login/pw --- auroraplus/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auroraplus/__init__.py b/auroraplus/__init__.py index 079ecec..4207dc6 100644 --- a/auroraplus/__init__.py +++ b/auroraplus/__init__.py @@ -167,7 +167,7 @@ def _include_access_token(self, r) -> dict: return r - def get_token(self, username=None, password=None): + def gettoken(self, username=None, password=None): """ Deprecated, kept for backward compatibility """ From 1af641c4d42a248092cce100276fb2a90fc9cf4e Mon Sep 17 00:00:00 2001 From: Olivier Mehani Date: Wed, 29 Nov 2023 20:44:18 +1100 Subject: [PATCH 14/19] fixup! fixup! Add methods to issue a new OAuth token --- readme.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/readme.md b/readme.md index d4b05fa..2300d27 100644 --- a/readme.md +++ b/readme.md @@ -25,9 +25,10 @@ of the error page and continue. >>> api.oauth_redirect('https://my.auroraenergy.com.au/login/redirect?state=...') {'id_token': 'ey...', 'access_token': 'ey...', 'token_type': 'bearer', ...} -The `api` object is now ready to use. The token returned in the last step can be -saved for later use when re-initialising the `api` object without having to follow -the OAuth flow to obtain a new authorisation. +The `api` object is now ready to use (start with `api.get_info()`. The token +returned in the last step can be saved for later use when re-initialising the +`api` object without having to follow the OAuth flow to obtain a new +authorisation. ### Connect to Aurora+ API with a pre-issued token From f930e97fac8cba8501ad1cbdfe53874fabbf6876 Mon Sep 17 00:00:00 2001 From: Olivier Mehani Date: Wed, 27 Dec 2023 16:40:35 +1100 Subject: [PATCH 15/19] squash! fixup! fixup! Add methods to issue a new OAuth token Document authentication flow in readme and docstring --- auroraplus/__init__.py | 42 +++++++++++++++++++++++++++++++++++++++++- readme.md | 9 ++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/auroraplus/__init__.py b/auroraplus/__init__.py index 4207dc6..a0065e9 100644 --- a/auroraplus/__init__.py +++ b/auroraplus/__init__.py @@ -12,6 +12,37 @@ class api: + ''' + A client to interact with the Aurora+ API. + + Obtaining a new OAuth token is done in two steps: + + >>> import auroraplus + >>> api = auroraplus.api() + >>> api.oauth_authorize() + 'https://customers.auroraenergy.com.au/...' + + After following the prompts, the URL of the (error) page that's returned should be passed as the redirect URI. + + >>> token = api.oauth_redirect('https://my.auroraenergy.com.au/login/redirect?state=...') + + The `api` object is now authenticated. + + + Data can then be fetched with + + >>> api.getcurrent() + >>> api.getday() + >>> api.getmonth() + >>> api.getquarter() + >>> api.getyear() + + and inspected in, e.g., + + >>> api.day + {'StartDate': '2023-12-25T13:00:00Z', 'EndDate': '2023-12-26T13:00:00Z', 'TimeMeasureCount': 1, ... + + ''' USER_AGENT = 'python/auroraplus' OAUTH_BASE_URL = 'https://customers.auroraenergy.com.au' \ @@ -31,6 +62,15 @@ def __init__(self, token: dict = None): """Initialise the API. + An authenticated object can be recreated from a preexisting OAuth `token` with + + >>> api = auroraplus.api(token) + + + Alternatively, a backward compatible way exists to only pass the `access_token` + + >>> api = auroraplus.api(password=token['access_token']) + Parameters: ----------- @@ -51,7 +91,7 @@ def __init__(self, backward_compat = False if not token and password and not username: # Backward compatibility: if no username and no token, - # assume the passowrd is a bearer access token + # assume the password is a bearer access token token = {'access_token': password, 'token_type': 'bearer'} backward_compat = True self.token = token diff --git a/readme.md b/readme.md index 2300d27..f69eff0 100644 --- a/readme.md +++ b/readme.md @@ -16,7 +16,7 @@ the AuroraPlus web application. >>> import auroraplus >>> api = auroraplus.api() >>> api.oauth_authorize() - 'https://customers.auroraenergy.com.au/auroracustomers1p.onmicrosoft.com/b2c_1a_sign_in//oauth2/v2.0/authorize?response_type=code&client_id=2ff9da64-8629-4a92-a4b6-850a3f02053d&redirect_uri=https%3A%2F%2Fmy.auroraenergy.com.au%2Flogin%2Fredirect&scope=openid+profile+offline_access&state=...&client_info=1' + 'https://customers.auroraenergy.com.au/auroracustomers1p.onmicrosoft.com/b2c_1a_sign_in//oauth2/v2.0/authorize?response_type=code&client_id=2ff9da64-8629-4a92-a4b6-850a3f02053d&redirect_uri=https%3A%2F%2Fmy.auroraenergy.coom.au%2Flogin%2Fredirect&scope=openid+profile+offline_access&state=...&client_info=1' Follow the URL above in a browser to authenticate with username+password and MFA. This will redirect to an error page (Cradle Mountain). Copy the full URL, @@ -36,6 +36,13 @@ authorisation. AuroraPlus = auroraplus.api(token={"access_token": "...", "token_type": "bearer"}) AuroraPlus.get_info() +For backward compatibility with users of the login/password method, the +`access_token` can be passed as the `password` if the `user` is empty. + + import auroraplus + AuroraPlus = auroraplus.api(password=) + AuroraPlus.get_info() + ### Get current account information AuroraPlus.getcurrent() From b6288b5d505de07da94bb55a43d8088eb5fe8c01 Mon Sep 17 00:00:00 2001 From: Olivier Mehani Date: Sun, 28 Jan 2024 23:38:01 +1100 Subject: [PATCH 16/19] Add script to get a new token Signed-off-by: Olivier Mehani --- auroraplus/__init__.py | 1 + readme.md | 7 +++++-- setup.cfg | 4 ++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/auroraplus/__init__.py b/auroraplus/__init__.py index a0065e9..a1bd29d 100644 --- a/auroraplus/__init__.py +++ b/auroraplus/__init__.py @@ -10,6 +10,7 @@ from requests.exceptions import Timeout from requests_oauthlib import OAuth2Session +from .get_token import get_token class api: ''' diff --git a/readme.md b/readme.md index f69eff0..2b0d01b 100644 --- a/readme.md +++ b/readme.md @@ -10,8 +10,11 @@ AuroraPlus is a package to pull data from https://api.auroraenergy.com.au/api. T ### Obtain a token -Obtaining a token is an interactive process where the user needs to log on to -the AuroraPlus web application. +The easieast way to obtain a new token is to use the `auroraplus_get_token` +script. + +To do this more programatically, obtaining a token is an interactive process +where the user needs to log on to the AuroraPlus web application. >>> import auroraplus >>> api = auroraplus.api() diff --git a/setup.cfg b/setup.cfg index e2a7a51..31a594e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,7 @@ # Inside of setup.cfg [metadata] description_file = readme.md + +[options.entry_points] +console_scripts = + auroraplus_get_token = auroraplus:get_token From e2733da7fbd14780a84fd219dde007474a35460a Mon Sep 17 00:00:00 2001 From: Olivier Mehani Date: Sun, 11 Feb 2024 21:18:54 +1100 Subject: [PATCH 17/19] squash! Add script to get a new token Actually add the token-getting script... --- auroraplus/get_token.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100755 auroraplus/get_token.py diff --git a/auroraplus/get_token.py b/auroraplus/get_token.py new file mode 100755 index 0000000..8f46d9a --- /dev/null +++ b/auroraplus/get_token.py @@ -0,0 +1,28 @@ +#!/bin/env python + +import auroraplus + + +def get_token(): + api = auroraplus.api() + url = api.oauth_authorize() + + print("Please visit the following URL in a browser, " + "and follow the login prompts ...\n") + print(url) + + print("\nThis will redirect to an error page (Cradle Mountain).\n") + + redirect_uri = input("Please enter the full URL of the error page: ") + + token = api.oauth_redirect(redirect_uri) + + print("\nThe new token is\n") + print(token) + + print("\n The access token is\n") + print(token['access_token']) + + +if __name__ == "__main__": + get_token() From d05929d54fcffaa97dcfae7623880eccd62324c6 Mon Sep 17 00:00:00 2001 From: Olivier Mehani Date: Mon, 1 Jul 2024 23:48:12 +1000 Subject: [PATCH 18/19] Allow to dump and load OAuth state Signed-off-by: Olivier Mehani --- auroraplus/__init__.py | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/auroraplus/__init__.py b/auroraplus/__init__.py index a1bd29d..eb42fa0 100644 --- a/auroraplus/__init__.py +++ b/auroraplus/__init__.py @@ -141,7 +141,7 @@ def oauth_authorize(self) -> str: hashlib.sha256(self.code_verifier.encode()).digest() ).strip(b'=') - authorization_url, _ = self.session.authorization_url( + self.authorization_url, _ = self.session.authorization_url( self.AUTHORIZE_URL, client_request_id=uuid.uuid4(), client_info=1, @@ -149,7 +149,7 @@ def oauth_authorize(self) -> str: code_challenge_method='S256', state=base64.encodebytes(json.dumps(state).encode())) - return authorization_url + return self.authorization_url def oauth_redirect(self, authorization_response: str): """ @@ -181,6 +181,36 @@ def oauth_redirect(self, authorization_response: str): code_verifier=self.code_verifier, ) + def oauth_dump(self) -> dict: + """ + Export partial OAuth state, for use in asynchronous or request/response-based + workflows. + + Returns: + -------- + + dict: a dict of all the relevant state + """ + return { + 'authorization_url': self.authorization_url, + 'code_verifier': self.code_verifier, + } + + def oauth_load(self, + authorization_url: str, + code_verifier: str, + ): + """ + Import partial OAuth state. + + Params: + ------- + + kwargs: pass the state dict returned from oauth_dump. + """ + self.authorization_url = authorization_url + self.code_verifier = code_verifier + def _include_access_token(self, r) -> dict: """ OAuth compliance hook to fetch the bespoke LoginToken, From 4db38df7f5ae8f08ba03681a7bc5fdb2d61bb49f Mon Sep 17 00:00:00 2001 From: Olivier Mehani Date: Wed, 14 May 2025 21:00:38 +1000 Subject: [PATCH 19/19] Install brotli AuroraPlus now returns br-encoded token data, so we need to be able to decompress it. --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index b2c00dd..5d4586e 100644 --- a/setup.py +++ b/setup.py @@ -19,6 +19,7 @@ url='https://github.com/leighcurran/AuroraPlus', keywords=['Aurora+', 'AuroraPlus', 'Aurora', 'Tasmania', 'API'], install_requires=[ + 'brotli', 'requests', 'requests_oauthlib', ],