├── tests ├── __init__.py ├── scratchpad.py ├── setup.py ├── test_biscuits.py ├── test_apikey_auth.py ├── defunct_create_users_and_join_sub.py ├── test_realwork_internal.py ├── _create_table_and_view.py ├── test_create_drop_table_views.py ├── test_sxtbaseapi.py ├── test_sxtresource.py └── test_spaceandtime.py ├── MANIFEST.in ├── requirements.txt ├── src └── spaceandtime │ ├── .env.sample │ ├── __init__.py │ ├── __main__.py │ ├── apiversions.json │ ├── sxtenums.py │ ├── sxtexceptions.py │ ├── sxtkeymanager.py │ ├── sxtbiscuits.py │ ├── spaceandtime.py │ ├── sxtuser.py │ └── sxtbaseapi.py ├── run_test_py3x.sh ├── LICENSE ├── pyproject.toml ├── .gitignore └── README.MD /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include src/spaceandtime/apiversions.json -------------------------------------------------------------------------------- /tests/scratchpad.py: -------------------------------------------------------------------------------- 1 | from spaceandtime import SpaceAndTime, SXTTable 2 | 3 | tbl = SXTTable('SXTDEMO.Stocks_Permissioned') -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyNaCl==1.5.0 2 | python-dotenv==1.0.0 3 | Requests==2.31.0 4 | pandas >=2.1.3 5 | pyarrow >= 14.0.1 6 | fastparquet >= 2023.10.1 7 | biscuit-python >= 0.2.0 8 | -------------------------------------------------------------------------------- /src/spaceandtime/.env.sample: -------------------------------------------------------------------------------- 1 | API_URL="https://example.com" 2 | USERID="user123" 3 | USER_PRIVATE_KEY="Td/IhCO/j3YWGbzvIgTTFgNVK60P4V2wFqho9ajN2Yc=" 4 | USER_PUBLIC_KEY="jx2dxJGVfC1cppytY0zUGoITMn2UsYaVBvTBHh9Cjhs=" -------------------------------------------------------------------------------- /src/spaceandtime/__init__.py: -------------------------------------------------------------------------------- 1 | from .spaceandtime import SpaceAndTime 2 | from .sxtbaseapi import SXTBaseAPI 3 | from .sxtbiscuits import SXTBiscuit 4 | from .sxtkeymanager import SXTKeyManager 5 | from .sxtresource import SXTResource, SXTTable, SXTView, SXTMaterializedView 6 | from .sxtuser import SXTUser 7 | from .sxtenums import * 8 | from .sxtexceptions import * -------------------------------------------------------------------------------- /tests/setup.py: -------------------------------------------------------------------------------- 1 | # use the venv virtual environment: 2 | # cd /Users/stephen.hilton/Dev/SxT-Python-SDK/tests 3 | # . ./venv/bin/activate 4 | # ./tests/venv/bin/python 5 | # pip3 install -r requirements.txt 6 | 7 | from pathlib import Path 8 | import sys 9 | path_root = Path(Path(__file__).parents[1] / 'src').resolve() 10 | sys.path.append(str(path_root)) 11 | -------------------------------------------------------------------------------- /run_test_py3x.sh: -------------------------------------------------------------------------------- 1 | cd /Users/stephen.hilton/Dev/SxT-Python-SDK 2 | 3 | # -- py3.10.13 4 | python3.10 -m venv venv_310 5 | . ./venv_310/bin/activate 6 | pip install --upgrade pip 7 | pip3 install -r requirements.txt 8 | pip3 install pytest 9 | echo RUNNING PYTHON 3.10 TESTING 10 | pytest --verbose 11 | deactivate 12 | 13 | # -- py3.11 14 | python3.11 -m venv venv_311 15 | . ./venv_311/bin/activate 16 | pip install --upgrade pip 17 | pip3 install -r requirements.txt 18 | pip3 install pytest 19 | echo RUNNING PYTHON 3.11 TESTING 20 | pytest --verbose 21 | deactivate 22 | -------------------------------------------------------------------------------- /src/spaceandtime/__main__.py: -------------------------------------------------------------------------------- 1 | # this is called when someone runs the package using the -m option, i.e., 2 | # python3 -m spaceandtime 3 | 4 | from spaceandtime import SpaceAndTime 5 | from pathlib import Path 6 | import sys 7 | 8 | def main(): 9 | 10 | # if env file path is supplied: 11 | if len(sys.argv) >1: 12 | envpath = Path(sys.argv[1]).resolve() 13 | sxt = SpaceAndTime(envpath) 14 | else: 15 | sxt = SpaceAndTime() 16 | 17 | sxt.authenticate() 18 | 19 | print( f'Authenticated UserID: {sxt.user}\nAccess Token:\n {sxt.access_token}' ) 20 | 21 | 22 | if __name__ == "__main__": 23 | main() -------------------------------------------------------------------------------- /tests/test_biscuits.py: -------------------------------------------------------------------------------- 1 | import os, sys, pytest, pandas, random 2 | from pathlib import Path 3 | from datetime import datetime 4 | from pprint import pprint 5 | from dotenv import load_dotenv 6 | 7 | # load local copy of libraries 8 | sys.path.append(str( Path(Path(__file__).parents[1] / 'src').resolve() )) 9 | from spaceandtime.spaceandtime import SpaceAndTime 10 | from spaceandtime.spaceandtime import SXTUser 11 | from spaceandtime.sxtkeymanager import SXTKeyManager 12 | from spaceandtime.sxtbiscuits import SXTBiscuit 13 | from spaceandtime.sxtexceptions import * # only contains exceptions prefixed with "SXT" 14 | 15 | 16 | 17 | 18 | km = SXTKeyManager(new_keypair=True) 19 | my_private_key = km.private_key 20 | 21 | 22 | from spaceandtime import SXTBiscuit, SXTTable 23 | 24 | mytable = SXTTable('schema.myTable', private_key=my_private_key) 25 | mytable.add_biscuit('Admin', SXTBiscuit.GRANT.ALL) 26 | 27 | 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Space and Time 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # pyproject.toml 2 | 3 | [build-system] 4 | requires = ["setuptools", "wheel"] 5 | build-backend = "setuptools.build_meta" 6 | 7 | [project] 8 | name = "spaceandtime" 9 | version = "1.1.73" 10 | description = "SDK for Space and Time verifiable database" 11 | authors = [{ name = "Stephen Hilton", email = "stephen@spaceandtime.io" }] 12 | readme = "README.md" 13 | requires-python = ">=3.10" 14 | license = { file = "LICENSE" } 15 | classifiers = [ 16 | "License :: OSI Approved :: MIT License", 17 | "Programming Language :: Python", 18 | "Programming Language :: Python :: 3", 19 | ] 20 | keywords = ["space and time", "sxt", "spaceandtime", "verifiable", "database", "web3", "blockchain", "data warehouse", "data"] 21 | dependencies = [ 22 | "PyNaCl==1.5.0", 23 | "python-dotenv==1.0.0", 24 | "Requests==2.31.0", 25 | "pandas >=2.1.3", 26 | "pyarrow >= 14.0.1", 27 | "fastparquet >= 2023.10.1", 28 | "biscuit-python >= 0.2.0" 29 | ] 30 | 31 | 32 | [project.optional-dependencies] 33 | dev = ["pip-tools", "pytest"] 34 | 35 | [project.urls] 36 | Homepage = "https://spaceandtime.io" 37 | Docs = "https://docs.spaceandtime.io" 38 | Documentation = "https://docs.spaceandtime.io" 39 | Github = "https://github.com/spaceandtimelabs/SxT-Python-SDK" 40 | 41 | [project.scripts] 42 | sxtlogin = "spaceandtime.__main__:main" 43 | 44 | [tool.setuptools.packages.find] 45 | where = ["src"] 46 | # include = ["my_package*"] # package names should match these glob patterns (["*"] by default) 47 | # exclude = ["my_package.tests*"] # exclude packages matching these glob patterns (empty by default) -------------------------------------------------------------------------------- /src/spaceandtime/apiversions.json: -------------------------------------------------------------------------------- 1 | { 2 | "auth/token": "v1", 3 | "auth/refresh": "v1", 4 | "auth/logout": "v1", 5 | "auth/code": "v1", 6 | "auth/code-register": "v1", 7 | "auth/validtoken": "v1", 8 | "auth/idexists/{id}": "v1", 9 | "auth/keys/code": "v1", 10 | "auth/keys": "v1", 11 | "sql": "v1", 12 | "sql/ddl": "v1", 13 | "sql/dml": "v1", 14 | "sql/dql": "v1", 15 | "encryption/sql/dql": "v1", 16 | "encryption/sql/dml": "v1", 17 | "encryption/configure": "v1", 18 | "sql/queries/{queryName}": "v2", 19 | "sql/queries-by-id/{queryId}": "v2", 20 | "sql/tamperproof-query": "v2", 21 | "discover/schema": "v2", 22 | "discover/table": "v2", 23 | "discover/view": "v2", 24 | "discover/table/column": "v2", 25 | "discover/table/index": "v2", 26 | "discover/table/primarykey": "v2", 27 | "discover/table/relations": "v2", 28 | "discover/refs/primarykey": "v2", 29 | "discover/refs/foreignkey": "v2", 30 | "discover/blockchains": "v2", 31 | "discover/blockchains/{chainId}/schemas": "v2", 32 | "discover/blockchains/{chainId}/meta": "v2", 33 | "subscription": "v1", 34 | "subscription/invite": "v1", 35 | "subscription/invite/{joinCode}": "v1", 36 | "subscription/leave": "v1", 37 | "subscription/remove/{userId}": "v1", 38 | "subscription/setrole/{userId}": "v1", 39 | "subscription/users": "v1", 40 | "subscription/name": "v1" 41 | } -------------------------------------------------------------------------------- /tests/test_apikey_auth.py: -------------------------------------------------------------------------------- 1 | import os, sys 2 | from pathlib import Path 3 | from datetime import datetime 4 | from dotenv import load_dotenv 5 | 6 | # load local copy of libraries 7 | sys.path.append(str( Path(Path(__file__).parents[1] / 'src').resolve() )) 8 | from spaceandtime.spaceandtime import SpaceAndTime 9 | from spaceandtime.spaceandtime import SXTUser 10 | from spaceandtime.sxtkeymanager import SXTKeyManager 11 | from spaceandtime.sxtbiscuits import SXTBiscuit 12 | from spaceandtime.sxtexceptions import * # only contains exceptions prefixed with "SXT" 13 | 14 | ENV = Path(Path(__file__).parents[1] / '.env') 15 | load_dotenv(ENV, override=True) 16 | API_URL = os.getenv('API_URL') 17 | USER_API_KEY = os.getenv('USER_API_KEY') 18 | SXT_STATE_BISCUIT = os.getenv('SXT_STATE_BISCUIT') 19 | SXTLABS_BISCUIT = os.getenv('SXTLABS_BISCUIT') 20 | SXT_TELEM_BISCUIT = os.getenv('SXT_TELEM_BISCUIT') 21 | 22 | 23 | def test_apikey_login(): 24 | 25 | # authenticate with API Key, throwing env file location off: 26 | sxt = SpaceAndTime() 27 | sxt.user.private_key = '' 28 | sxt.user.public_key = '' 29 | sxt.user.user_id = '' 30 | 31 | sxt.user.api_key = USER_API_KEY 32 | sxt.authenticate() 33 | 34 | assert not sxt.user.access_expired 35 | assert sxt.user.exists 36 | print(sxt.user.user_id) 37 | assert sxt.user.user_id == 'pySDK_tester' 38 | assert not sxt.user.is_trial 39 | assert not sxt.user.is_quota_exceeded 40 | assert not sxt.user.is_restricted 41 | assert len(sxt.access_token) > 0 42 | assert len(sxt.user.subscription_id) > 0 43 | assert sxt.user.public_key == '' 44 | assert sxt.user.private_key == '' 45 | 46 | # execute test query 47 | success, data = sxt.execute_query(biscuits=[SXTLABS_BISCUIT, SXT_STATE_BISCUIT], 48 | sql_text="""Select * from sxtlabs.singularity""") 49 | assert success 50 | assert len(data) == 1 51 | 52 | 53 | if __name__ == "__main__": 54 | test_apikey_login() 55 | pass -------------------------------------------------------------------------------- /src/spaceandtime/sxtenums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | class SXTPermission(Enum): 4 | SELECT = 'dql_select' 5 | INSERT = 'dml_insert' 6 | UPDATE = 'dml_update' 7 | DELETE = 'dml_delete' 8 | MERGE = 'dml_merge' 9 | CREATE = 'ddl_create' 10 | ALTER = 'ddl_alter' 11 | DROP = 'ddl_drop' 12 | ALL = '*' 13 | def __str__(self) -> str: 14 | return super().__str__() 15 | 16 | 17 | class SXTKeyEncodings(Enum): 18 | HEX = 'hex' 19 | BASE64 = 'base64' 20 | BYTES = 'bytes' 21 | def __str__(self) -> str: 22 | return super().__str__() 23 | 24 | 25 | class SXTApiCallTypes(Enum): 26 | POST = 'post' 27 | GET = 'get' 28 | PUT = 'put' 29 | DELETE = 'delete' 30 | def __str__(self) -> str: 31 | return super().__str__() 32 | 33 | 34 | class SXTSqlType(Enum): 35 | DDL = 'ddl' 36 | DML = 'dml' 37 | DQL = 'dql' 38 | def __str__(self) -> str: 39 | return super().__str__() 40 | 41 | 42 | class SXTOutputFormat(Enum): 43 | JSON = 'json' 44 | CSV = 'csv' 45 | DATAFRAME = 'dataframe' 46 | PARQUET = 'parquet' 47 | def __str__(self) -> str: 48 | return super().__str__() 49 | 50 | 51 | class SXTTableAccessType(Enum): 52 | PERMISSIONED = 'permissioned' 53 | PUBLIC_READ = 'public_read' 54 | PUBLIC_APPEND = 'public_append' 55 | PUBLIC_WRITE = 'public_write' 56 | def __str__(self) -> str: 57 | return super().__str__() 58 | 59 | 60 | class SXTResourceType(Enum): 61 | UNDEFINED = 'undefined' 62 | TABLE = 'table_name' 63 | VIEW = 'view_name' 64 | MATERIALIZED_VIEW = 'matview_name' 65 | PARAMETERIZED_VIEW = 'parmview_name' 66 | KAFKA_STREAM = 'kafka_name' 67 | def __str__(self) -> str: 68 | return super().__str__() 69 | 70 | 71 | class SXTDiscoveryScope(Enum): 72 | SUBSCRIPTION = 'subscription' 73 | PUBLIC = 'public' 74 | ALL = 'all' 75 | def __str__(self) -> str: 76 | return super().__str__() 77 | 78 | -------------------------------------------------------------------------------- /src/spaceandtime/sxtexceptions.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | def log_if_logger(*args, **kwargs) -> None: 4 | 5 | # use supplied logger if supplied, or root if it has been setup, otherwise no logger, just exit 6 | if 'logger' in kwargs and type(kwargs['logger']) in [logging.RootLogger, logging.Logger]: 7 | errlogger = kwargs['logger'] 8 | else: 9 | try: 10 | if logging.getLogger().hasHandlers(): 11 | errlogger = logging.getLogger() 12 | else: 13 | return None 14 | except: 15 | return None 16 | 17 | # use message if supplied in kwargs, or as pos0 in args 18 | if 'message' in kwargs: 19 | msg = kwargs['message'] 20 | else: 21 | msg = 'No message supplied' if len(args) == 0 else str(args[0]) 22 | 23 | # log message 24 | errlogger.error(msg) 25 | return None 26 | 27 | 28 | class SxTBiscuitError(Exception): 29 | def __init__(self, *args: object, **kwargs) -> None: 30 | log_if_logger(*args, **kwargs) 31 | super().__init__(*args) 32 | 33 | class SxTKeyEncodingError(Exception): 34 | def __init__(self, *args: object, **kwargs) -> None: 35 | log_if_logger(*args, **kwargs) 36 | super().__init__(*args) 37 | 38 | class SxTArgumentError(Exception): 39 | def __init__(self, *args: object, **kwargs) -> None: 40 | log_if_logger(*args, **kwargs) 41 | super().__init__(*args) 42 | 43 | class SxTFileContentError(Exception): 44 | def __init__(self, *args: object, **kwargs) -> None: 45 | log_if_logger(*args, **kwargs) 46 | super().__init__(*args) 47 | 48 | class SxTQueryError(Exception): 49 | def __init__(self, *args: object, **kwargs) -> None: 50 | log_if_logger(*args, **kwargs) 51 | super().__init__(*args) 52 | 53 | class SxTAuthenticationError(Exception): 54 | def __init__(self, *args: object, **kwargs) -> None: 55 | log_if_logger(*args, **kwargs) 56 | super().__init__(*args) 57 | 58 | class SxTAPINotDefinedError(Exception): 59 | def __init__(self, *args: object, **kwargs) -> None: 60 | log_if_logger(*args, **kwargs) 61 | super().__init__(*args) 62 | 63 | 64 | class SxTAPINotSuccessfulError(Exception): 65 | def __init__(self, *args: object, **kwargs) -> None: 66 | log_if_logger(*args, **kwargs) 67 | super().__init__(*args) 68 | 69 | 70 | class SxTExceptions(): 71 | SxTAuthenticationError = SxTAuthenticationError 72 | SxTQueryError = SxTQueryError 73 | SxTFileContentError = SxTFileContentError 74 | SxTArgumentError = SxTArgumentError 75 | SxTKeyEncodingError = SxTKeyEncodingError 76 | SxTBiscuitError = SxTBiscuitError 77 | SxTAPINotDefinedError = SxTAPINotDefinedError 78 | SxTAPINotSuccessfulError = SxTAPINotSuccessfulError -------------------------------------------------------------------------------- /tests/defunct_create_users_and_join_sub.py: -------------------------------------------------------------------------------- 1 | import os, sys, pytest, pandas, random 2 | from pathlib import Path 3 | 4 | # load local copy of libraries 5 | sys.path.append(str( Path(Path(__file__).parents[1] / 'src').resolve() )) 6 | from spaceandtime.spaceandtime import SXTUser 7 | 8 | 9 | # with recent changes to user / subscription management, these tests are now defunct. 10 | # basically, users cannot be generated without a valid email, making it much harder to test. 11 | # I'll leave this code here for now, as it's a useful reference, but it's no longer used. 12 | 13 | 14 | def test_remove_all_users_from_test_subscription(): 15 | # login with admin 16 | admin = SXTUser(dotenv_file='.env_loader_admin') 17 | admin.authenticate() 18 | assert admin.user_id == 'testuser_owner' 19 | 20 | # get list of all users in subscription 21 | success, users = admin.get_subscription_users() 22 | assert success 23 | for userid, role in users.items(): 24 | if userid == admin.user_id: continue # skip self 25 | 26 | # remove all other users from subscription 27 | success, response = admin.remove_from_subscription(userid) 28 | assert success 29 | 30 | success, users = admin.get_subscription_users() 31 | assert len(users) == 1 32 | 33 | 34 | def defunct_adding_users_to_subscription(): 35 | # this test is now defunct, with recent changes to adding / managing users and subscriptiions 36 | # login with admin 37 | steve = SXTUser(dotenv_file='.env_loader_admin') 38 | steve.authenticate() 39 | assert steve.user_id == 'testuser_owner' 40 | 41 | sxtloader_users = [] 42 | 43 | # create N new load users 44 | for i in range(0,5): 45 | 46 | # load keys, then override name 47 | sxtloaderN = SXTUser(dotenv_file='.env_loader', 48 | user_id=f'testuser_joincode{i}_{str(random.randint(0,999999)).zfill(6)}',) 49 | assert 'disconnected' in sxtloaderN.subscription_id 50 | assert sxtloaderN.exists == False 51 | 52 | # register new user and add to subscription 53 | joincode = steve.generate_joincode(role='member') 54 | assert len(joincode) > 0 55 | 56 | success, response = sxtloaderN.register_new_user() 57 | assert sxtloaderN.exists == True 58 | assert sxtloaderN.subscription_id == '' 59 | first_access_token = sxtloaderN.access_token 60 | 61 | if success: success, response = sxtloaderN.join_subscription(joincode) 62 | assert sxtloaderN.subscription_id != '' 63 | assert 'disconnected' not in sxtloaderN.subscription_id 64 | assert first_access_token != sxtloaderN.access_token 65 | 66 | success, response = sxtloaderN.leave_subscription() 67 | 68 | 69 | if __name__ == '__main__': 70 | test_remove_all_users_from_test_subscription() 71 | # test_adding_users_to_subscription() 72 | pass -------------------------------------------------------------------------------- /tests/test_realwork_internal.py: -------------------------------------------------------------------------------- 1 | import os, sys, pytest, pandas, random 2 | from pathlib import Path 3 | from datetime import datetime 4 | from pprint import pprint 5 | from dotenv import load_dotenv 6 | 7 | # load local copy of libraries 8 | sys.path.append(str( Path(Path(__file__).parents[1] / 'src').resolve() )) 9 | from spaceandtime.spaceandtime import SpaceAndTime 10 | from spaceandtime.spaceandtime import SXTUser 11 | from spaceandtime.sxtkeymanager import SXTKeyManager 12 | from spaceandtime.sxtbiscuits import SXTBiscuit 13 | from spaceandtime.sxtexceptions import * # only contains exceptions prefixed with "SXT" 14 | 15 | ENV = Path(Path(__file__).parents[1] / '.env') 16 | load_dotenv(ENV, override=True) 17 | 18 | API_URL = os.getenv('API_URL') 19 | USER_API_KEY = os.getenv('USER_API_KEY') 20 | SXT_STATE_BISCUIT = os.getenv('SXT_STATE_BISCUIT') 21 | SXTLABS_BISCUIT = os.getenv('SXTLABS_BISCUIT') 22 | SXT_TELEM_BISCUIT = os.getenv('SXT_TELEM_BISCUIT') 23 | 24 | 25 | 26 | def test_telem_queries_in_schemas(): 27 | schemas = ['sui','sxtlabs','ethereum'] 28 | days = 14 29 | 30 | # authenticate with API Key, throwing env file location off: 31 | sxt = SpaceAndTime() 32 | sxt.user.private_key = '' 33 | sxt.user.public_key = '' 34 | sxt.user.user_id = '' 35 | 36 | sxt.user.api_key = USER_API_KEY 37 | sxt.authenticate() 38 | 39 | assert not sxt.user.access_expired 40 | assert sxt.user.exists 41 | print(sxt.user.user_id) 42 | assert sxt.user.user_id == 'pySDK_tester' 43 | assert not sxt.user.is_trial 44 | assert not sxt.user.is_quota_exceeded 45 | assert not sxt.user.is_restricted 46 | assert len(sxt.access_token) > 0 47 | assert len(sxt.user.subscription_id) > 0 48 | assert sxt.user.public_key == '' 49 | assert sxt.user.private_key == '' 50 | 51 | # run thru schema queries 52 | all_queries = [] 53 | for schema in schemas: 54 | sxt.logger.info(f'\n\nProcessing {schema} for the last {days} days\n{"-"*30}') 55 | 56 | schema_queries = [] 57 | sql = f""" 58 | Select 59 | '{schema.strip()}' as Schema_Name 60 | , coalesce(a.Account_Name,'Not Registered') as Sub_Name 61 | , count(distinct c.Subscription_ID) as Sub_Count 62 | , count(distinct c.USER_ID) as User_Count 63 | , count(*) as Queries 64 | , max(timestamp) as max_timestamp 65 | , min(timestamp) as min_timestamp 66 | , max(c.USER_ID) as max_UserID 67 | , min(c.USER_ID) as min_UserID 68 | FROM SXT_STATE.QUERY_META_CORE as c 69 | left outer join SXTLabs.CRM_Accounts as a 70 | on c.Subscription_ID = a.Subscription_ID 71 | WHERE c.SQL_Text ilike '%{ schema.strip() }.%' 72 | and cast(timestamp as date) > current_date - {days} 73 | and c.USER_ID not in('pySDK_tester','stephen') 74 | group by 1,2 order by 2 desc 75 | """ 76 | success, schema_queries = sxt.execute_query(sql, biscuits=[SXTLABS_BISCUIT, SXT_STATE_BISCUIT] ) 77 | assert success 78 | 79 | all_queries = all_queries + schema_queries 80 | 81 | assert len(all_queries) == len(schemas) 82 | pprint(all_queries) 83 | pass 84 | 85 | 86 | 87 | if __name__ == "__main__": 88 | test_telem_queries_in_schemas() 89 | pass -------------------------------------------------------------------------------- /tests/_create_table_and_view.py: -------------------------------------------------------------------------------- 1 | import setup, teardown 2 | 3 | from pathlib import Path 4 | from random import randint 5 | from pprint import pprint 6 | 7 | from spaceandtime import SpaceAndTime, SXTTable, SXTView 8 | 9 | 10 | # conventions for ease of testing... 11 | randnum = str(randint(0,999999)).rjust(6,'0') 12 | thisfile = Path(__file__) 13 | 14 | 15 | # connect to the network and authenticate (with local .env file) 16 | sxt = SpaceAndTime( application_name = thisfile.stem , default_local_folder = thisfile.parent ) 17 | sxt.authenticate() 18 | 19 | 20 | # create a test table: 21 | myTable = SXTTable(f'TEMP.MyTable_{randnum}', access_type=sxt.TABLE_ACCESS.PERMISSSIONED, 22 | new_keypair=True, SpaceAndTime_parent=sxt) 23 | myTable.create_ddl = """ 24 | CREATE TABLE {table_name} 25 | ( MyID int 26 | , MyName varchar 27 | , MyDate date 28 | , Primary Key (MyID) 29 | ) {with_statement} 30 | """ 31 | 32 | # create biscuits 33 | myTable.add_biscuit('Admin', sxt.GRANT.ALL) 34 | myTable.add_biscuit('Read', sxt.GRANT.SELECT) 35 | myTable.add_biscuit('Load', sxt.GRANT.SELECT, sxt.GRANT.INSERT, sxt.GRANT.DELETE, sxt.GRANT.UPDATE) 36 | 37 | # create table 38 | myTable.save() 39 | success, results = myTable.create() 40 | 41 | if success: 42 | 43 | # insert some data: 44 | myData = [ {'MyID':i, 'MyName':chr(64+i), 'MyDate':f'2023-09-0{i}' } for i in list(range(1,11))] 45 | success, results = myTable.insert.with_list_of_dicts(myData) 46 | 47 | # quick select data from DB (use sxt.execute_query() for full SQL) 48 | success, data = myTable.select() 49 | pprint(data) 50 | 51 | # delete half the records 52 | myTable.delete(where='MyID > 5') 53 | 54 | # quick select data from DB 55 | pprint( myTable.select() ) 56 | 57 | 58 | # create a view (with same key as table) 59 | myView = SXTView(f'TEMP.myView_{randnum}', private_key=myTable.private_key, SpaceAndTime_parent=sxt) 60 | myView.create_ddl = f""" 61 | CREATE VIEW {myView.view_name} 62 | {myView.with_statement} 63 | AS 64 | SELECT * FROM {myTable.table_name} 65 | WHERE MyID in(2,4,6,8) """ 66 | myView.add_biscuit('Admin', sxt.GRANT.ALL) 67 | myView.table_biscuit = myTable.get_biscuit('Read') # required to prove authorization 68 | 69 | myView.save() 70 | success, results = myView.create() 71 | 72 | if success: pprint( myView.select() ) 73 | 74 | input('Last chance to use Debug Console to play around, beforw we start dropping objects for clean-up...') 75 | 76 | 77 | view_drop_success, view_drop_results = myView.drop() 78 | table_drop_success, table_drop_results = myTable.drop() 79 | 80 | # if we've dropped both view and table, we can clean up files (if desired) 81 | if view_drop_success and table_drop_success: 82 | Path(myView.recommended_filename).unlink(True) 83 | Path(myTable.recommended_filename).unlink(True) 84 | 85 | 86 | 87 | 88 | 89 | 90 | # What if you want to raise error on any failure, instead of testing for Success? 91 | errTable = SXTTable(f'TEMP.errTable_{randnum}', access_type=sxt.TABLE_ACCESS.PERMISSSIONED, 92 | new_keypair=True, SpaceAndTime_parent=sxt) 93 | errTable.create_ddl = """ 94 | CREATE TABLE {table_name} 95 | ( MyID Bad_DataType -- This will error 96 | , MyName varchar 97 | , Primary Key (MyID) 98 | ) {with_statement} 99 | """ 100 | errTable.add_biscuit('Admin', sxt.GRANT.ALL) 101 | success, results = errTable.create() 102 | 103 | if not success: errTable.raise_error() 104 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.py[cod] 3 | *$py.class 4 | resources/ 5 | tables/ 6 | users/ 7 | skip/ 8 | old/ 9 | tests/tables 10 | tests/views 11 | tests/scratchpad* 12 | projects/ 13 | logs/ 14 | build_biscuit-python/ 15 | publish.sh 16 | .env_* 17 | venv_* 18 | tests/backup_biscuits/ 19 | tests/latest_test_log.txt 20 | tests/user_saves 21 | load_IPwhitelist/ 22 | 23 | 24 | # C extensions 25 | *.so 26 | 27 | # Distribution / packaging 28 | .Python 29 | build/ 30 | develop-eggs/ 31 | dist/ 32 | downloads/ 33 | eggs/ 34 | .eggs/ 35 | lib/ 36 | lib64/ 37 | parts/ 38 | sdist/ 39 | var/ 40 | wheels/ 41 | share/python-wheels/ 42 | *.egg-info/ 43 | .installed.cfg 44 | *.egg 45 | MANIFEST 46 | 47 | # PyInstaller 48 | # Usually these files are written by a python script from a template 49 | # before PyInstaller builds the exe, so as to inject date/other infos 50 | into it. 51 | *.manifest 52 | *.spec 53 | 54 | # Installer logs 55 | pip-log.txt 56 | pip-delete-this-directory.txt 57 | 58 | # Unit test / coverage reports 59 | htmlcov/ 60 | .tox/ 61 | .nox/ 62 | .coverage 63 | .coverage.* 64 | .cache 65 | nosetests.xml 66 | coverage.xml 67 | *.cover 68 | *.py,cover 69 | .hypothesis/ 70 | .pytest_cache/ 71 | cover/ 72 | 73 | # Translations 74 | *.mo 75 | *.pot 76 | 77 | # Django stuff: 78 | *.log 79 | local_settings.py 80 | db.sqlite3 81 | db.sqlite3-journal 82 | 83 | # Flask stuff: 84 | instance/ 85 | .webassets-cache 86 | 87 | # Scrapy stuff: 88 | .scrapy 89 | 90 | # Sphinx documentation 91 | docs/_build/ 92 | 93 | # PyBuilder 94 | .pybuilder/ 95 | target/ 96 | 97 | # Jupyter Notebook 98 | .ipynb_checkpoints 99 | 100 | # IPython 101 | profile_default/ 102 | ipython_config.py 103 | 104 | # pyenv 105 | # For a library or package, you might want to ignore these files since 106 | the code is 107 | # intended to run in multiple environments; otherwise, check them in: 108 | # .python-version 109 | 110 | # pipenv 111 | # According to pypa/pipenv#598, it is recommended to include 112 | Pipfile.lock in version control. 113 | # However, in case of collaboration, if having platform-specific 114 | dependencies or dependencies 115 | # having no cross-platform support, pipenv may install dependencies that 116 | don't work, or not 117 | # install all needed dependencies. 118 | #Pipfile.lock 119 | 120 | # poetry 121 | # Similar to Pipfile.lock, it is generally recommended to include 122 | poetry.lock in version control. 123 | # This is especially recommended for binary packages to ensure 124 | reproducibility, and is more 125 | # commonly ignored for libraries. 126 | # 127 | https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 128 | #poetry.lock 129 | 130 | # pdm 131 | # Similar to Pipfile.lock, it is generally recommended to include 132 | pdm.lock in version control. 133 | #pdm.lock 134 | # pdm stores project-wide configurations in .pdm.toml, but it is 135 | recommended to not include it 136 | # in version control. 137 | # https://pdm.fming.dev/#use-with-ide 138 | .pdm.toml 139 | 140 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and 141 | github.com/pdm-project/pdm 142 | __pypackages__/ 143 | 144 | # Celery stuff 145 | celerybeat-schedule 146 | celerybeat.pid 147 | 148 | # SageMath parsed files 149 | *.sage.py 150 | 151 | # Environments 152 | .env 153 | .venv 154 | env/ 155 | venv/ 156 | ENV/ 157 | env.bak/ 158 | venv.bak/ 159 | 160 | # Spyder project settings 161 | .spyderproject 162 | .spyproject 163 | 164 | # Rope project settings 165 | .ropeproject 166 | 167 | # mkdocs documentation 168 | /site 169 | 170 | # mypy 171 | .mypy_cache/ 172 | .dmypy.json 173 | dmypy.json 174 | 175 | # Pyre type checker 176 | .pyre/ 177 | 178 | # pytype static type analyzer 179 | .pytype/ 180 | 181 | # Cython debug symbols 182 | cython_debug/ 183 | 184 | # PyCharm 185 | # JetBrains specific template is maintained in a separate 186 | JetBrains.gitignore that can 187 | # be found at 188 | https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 189 | # and can be added to the global gitignore or merged into this file. For 190 | a more nuclear 191 | # option (not recommended) you can uncomment the following to ignore the 192 | entire idea folder. 193 | #.idea/ 194 | 195 | source 196 | 197 | # Extra Files 198 | mysdk.py 199 | public-key-file.txt 200 | private-key-file.txt 201 | tester.py 202 | session.txt 203 | .DS_Store 204 | mocktest.py 205 | .env -------------------------------------------------------------------------------- /tests/test_create_drop_table_views.py: -------------------------------------------------------------------------------- 1 | import sys,random, time 2 | from pathlib import Path 3 | from datetime import datetime 4 | 5 | # load local copy of libraries 6 | sys.path.append(str( Path(Path(__file__).parents[1] / 'src').resolve() )) 7 | from spaceandtime.spaceandtime import SpaceAndTime 8 | from spaceandtime.sxtkeymanager import SXTKeyManager 9 | from spaceandtime.sxtbiscuits import SXTBiscuit 10 | from spaceandtime.sxtresource import SXTTable, SXTView, SXTMaterializedView 11 | 12 | 13 | # authenticate once for all subsequent tests 14 | envfile = Path(Path(__file__).parent / '.env').resolve() # ./tests/.env 15 | sxt = SpaceAndTime(envfile_filepath=envfile, application_name='pytest_SDKTesting') 16 | sxt.logger_addFileHandler( Path(envfile.parent / 'latest_test_log.txt') ) 17 | sxt.authenticate() 18 | 19 | 20 | def test_all(): 21 | num = '{num:06d}'.format(num=random.randint(0,999999)) 22 | biscuit_file = Path(Path(__file__).parent / 'backup_biscuits' / f"{datetime.now().strftime('%Y%m%d.%h%m%s')}_biscuits_from_tests.json") 23 | 24 | try: 25 | # create one keypair / biscuit for all use-cases and objects 26 | biscuit = SXTBiscuit('all_uses', new_keypair=True) 27 | biscuit.add_capability('*', sxt.GRANT.ALL) 28 | biscuit.save(biscuit_file) # in case something goes wrong... we can rm at the end 29 | 30 | # Create table 31 | table = SXTTable(f'SXTTest.MyTestTable_{num}', key_manager=biscuit.key_manager, access_type=sxt.TABLE_ACCESS.PUBLIC_READ, SpaceAndTime_parent=sxt) 32 | table.add_biscuit_object(biscuit) 33 | table.create_ddl = table.create_ddl_sample 34 | table_success, resopnse = table.create() 35 | assert table_success 36 | assert table.exists 37 | 38 | # load some made-up data 39 | data = [{'MyID':n, 'MyName':chr(n), 'MyDate':'2024-01-01'} for n in range(65,65+26)] 40 | insert_success, response = table.insert.with_list_of_dicts(data) 41 | assert insert_success 42 | 43 | # Select, using built-in function 44 | select_success, data = table.select() 45 | assert select_success 46 | assert len(data) == 26 47 | assert data[0]['MYDATE'][:10] == '2024-01-01' 48 | assert 64 < data[0]['MYID'] < 90 49 | assert sorted([d['MYID'] for d in data])[0] == 65 # sorted here, in python 50 | 51 | # Select, using custom SQL 52 | select_success, data = table.select(f'Select * from {table.table_name} order by MyID') 53 | assert select_success 54 | assert len(data) == 26 55 | assert data[0]['MYID'] == 65 # sorted in the SQL 56 | 57 | # create view on table 58 | view = SXTView(f'SXTTest.MyTestView_{num}_Evens', key_manager=biscuit.key_manager, SpaceAndTime_parent=sxt) 59 | view.add_biscuit_object(biscuit) 60 | view.create_ddl = f""" 61 | CREATE VIEW {view.view_name} 62 | {view.with_statement} AS 63 | SELECT * FROM {table.table_name} WHERE MyID % 2 = 0 """ 64 | view_success, response = view.create() 65 | assert view_success 66 | assert view.exists 67 | 68 | # views can take a moment for network to sync sometimes, let's check a few times 69 | for i in range(1,30): 70 | select_success, data = view.select() 71 | if select_success and len(data) == 13: break 72 | time.sleep(10) 73 | 74 | # select from the EVENS view (only even numbers) 75 | assert select_success 76 | assert len(data) == 13 77 | assert sorted([d['MYID'] for d in data])[0] == 66 78 | 79 | # create materialized view 80 | matview = SXTMaterializedView(f'SXTTest.MyTestMatView_{num}_Odds', key_manager=biscuit.key_manager, SpaceAndTime_parent=sxt) 81 | matview.add_biscuit_object(biscuit) 82 | matview.create_ddl_template = f""" 83 | CREATE VIEW {matview.matview_name} 84 | {matview.with_statement} AS 85 | SELECT * FROM {table.table_name} 86 | WHERE MyID not in ( Select MyID from {view.view_name} ) """ 87 | view_success, response = matview.create() 88 | assert view_success 89 | assert matview.exists 90 | 91 | for i in range(1,30): 92 | select_success, data = matview.select() 93 | if select_success and len(data) == 13: break 94 | time.sleep(10) 95 | 96 | # select from the ODDS materialized view (only odd numbers) 97 | assert select_success 98 | assert len(data) == 13 99 | assert sorted([d['MYID'] for d in data])[0] == 65 100 | 101 | 102 | except Exception as ex: 103 | # for any error, just try to drop everything 104 | pass 105 | 106 | # make sure to drop in reverse order of dependencies 107 | dropmatview_success = dropview_success = droptable_success = True 108 | if 'matview' in locals(): dropmatview_success, response = matview.drop() 109 | if 'view' in locals(): dropview_success, response = view.drop() 110 | if 'table' in locals(): droptable_success, response = table.drop() 111 | 112 | # if anything created fails to drop, hang onto the biscuit (or if ) 113 | if (dropmatview_success and dropview_success and droptable_success): 114 | Path(biscuit_file).unlink(True) 115 | 116 | 117 | 118 | if __name__ == '__main__': 119 | test_all() 120 | pass -------------------------------------------------------------------------------- /tests/test_sxtbaseapi.py: -------------------------------------------------------------------------------- 1 | import os, sys, pytest 2 | from dotenv import load_dotenv 3 | from pathlib import Path 4 | 5 | # load local copy of libraries 6 | sys.path.append(str( Path(Path(__file__).parents[1] / 'src').resolve() )) 7 | from spaceandtime.sxtbaseapi import SXTBaseAPI 8 | from spaceandtime.sxtkeymanager import SXTKeyManager 9 | from spaceandtime.sxtbiscuits import SXTBiscuit 10 | 11 | # load env variables 12 | load_dotenv(Path(Path(__file__).parent / '.env')) 13 | api_url = os.getenv('API_URL') 14 | userid = os.getenv('USERID') 15 | keys = SXTKeyManager(os.getenv('USER_PRIVATE_KEY')) 16 | 17 | # authenticate once for all subsequent tests 18 | sxtb = SXTBaseAPI() 19 | success, response = sxtb.get_auth_challenge_token(userid) 20 | challenge = response['authCode'] 21 | success, tokens = sxtb.auth_token(user_id=userid, keymanager=keys, challange_token=challenge) 22 | access_token = tokens['accessToken'] 23 | refresh_token = tokens['refreshToken'] 24 | 25 | 26 | def test_access_token_created(): 27 | # designed to test the root-level authentication 28 | assert access_token !='' 29 | 30 | 31 | def test_peripheral_functions(): 32 | sxtb = SXTBaseAPI() 33 | 34 | # prep biscuits 35 | assert ['a','b','c'] == sxtb.prep_biscuits(['a','b','c']) 36 | assert ['a','b','c','d','e'] == sxtb.prep_biscuits(['a','b',['c','d','e']]) 37 | assert ['a','b','c','d','e'] == sxtb.prep_biscuits([['a','b'],['c','d','e']]) 38 | bf = SXTBiscuit(biscuit_token='f') 39 | bg = SXTBiscuit(biscuit_token='g') 40 | assert ['a','b','c','d','e','f','g'] == sxtb.prep_biscuits([['a','b'],['c','d','e'],bf, bg]) 41 | 42 | # prep sql - removes newlines, tabs, double spaces, and trailing ';', except where they appear inside a string 43 | assert 'select * from schema.mytable' == sxtb.prep_sql('\n select * \n\tfrom schema.mytable ') 44 | assert 'select * from schema.mytable' == sxtb.prep_sql(' select * from schema.mytable') 45 | assert 'select "some \tstring" as colA from schema.mytable' == sxtb.prep_sql(' select "some \tstring" as colA \nfrom schema.mytable; ') 46 | 47 | 48 | def test_authenticate(): 49 | sxtb = SXTBaseAPI() 50 | keys.encoding = keys.ENCODINGS.BASE64 51 | 52 | # get challenge code 53 | success, response = sxtb.get_auth_challenge_token(userid) 54 | assert success 55 | 56 | # login with explicit signature 57 | challenge = response['authCode'] 58 | success, tokens = sxtb.auth_token(user_id=userid, public_key=keys.public_key_to(keys.ENCODINGS.BASE64), 59 | challange_token=challenge, signed_challange_token=keys.sign_message(challenge)) 60 | assert success 61 | 62 | # login with abbreviated keymanager signature 63 | success, response = sxtb.get_auth_challenge_token(userid) 64 | challenge = response['authCode'] 65 | success, tokens = sxtb.auth_token(user_id=userid, keymanager=keys, challange_token=challenge) 66 | assert success 67 | 68 | # wrong key 69 | keywrong = SXTKeyManager(new_keypair=True) 70 | success, response = sxtb.get_auth_challenge_token(userid) 71 | challenge = response['authCode'] 72 | success, tokens = sxtb.auth_token(user_id=userid, keymanager=keywrong, challange_token=challenge) 73 | assert (not success) 74 | 75 | # alias calls (with good key) 76 | success, response = sxtb.auth_code(userid) 77 | challenge = response['authCode'] 78 | success, tokens = sxtb.get_access_token(user_id=userid, keymanager=keys, challange_token=challenge) 79 | assert success 80 | 81 | 82 | def test_call_api(): 83 | sxtb = SXTBaseAPI(access_token) 84 | success, user_exists = sxtb.call_api('auth/idexists/{id}', auth_header=False, 85 | request_type=sxtb.APICALLTYPE.GET, 86 | path_parms={'id':userid}) 87 | assert success 88 | assert user_exists 89 | 90 | success, user_exists = sxtb.call_api('auth/idexists/{id}', auth_header=False, 91 | request_type=sxtb.APICALLTYPE.GET, 92 | path_parms={'id':'this_user_should_not_exist_please_dont_create'}) 93 | assert success 94 | assert not user_exists 95 | 96 | success, data = sxtb.call_api('sql', auth_header=True, request_type=sxtb.APICALLTYPE.POST, 97 | data_parms={'sqlText':'select * from sxtlabs.singularity'} ) 98 | assert success 99 | assert data[0]['NAME'] == 'Singularity' # hopefully this doesn't change... 100 | 101 | # what happens if we break it? 102 | # bad API 103 | success, data = sxtb.call_api('no_such_api', auth_header=False, request_type=sxtb.APICALLTYPE.POST) 104 | assert not success 105 | assert data['status_code'] == 555 106 | 107 | # good API, not validated 108 | success, data = sxtb.call_api('sql', auth_header=False, request_type=sxtb.APICALLTYPE.POST, 109 | data_parms={'sqlText':'select * from sxtlabs.singularity'} ) 110 | assert not success 111 | assert data['status_code'] == 401 112 | assert 'Unauthorized' in data['error'] 113 | assert 'JWT authorization failed' in data['text'] 114 | 115 | 116 | def test_logout(): 117 | sxtb = SXTBaseAPI(access_token) 118 | success, response = sxtb.auth_logout() 119 | # this API isn't working right now, so: 120 | success = True 121 | assert success 122 | 123 | 124 | if __name__ == '__main__': 125 | test_access_token_created() 126 | test_peripheral_functions() 127 | test_authenticate() 128 | test_call_api() 129 | test_logout() 130 | pass -------------------------------------------------------------------------------- /tests/test_sxtresource.py: -------------------------------------------------------------------------------- 1 | import os, sys, pytest, pandas, random 2 | from dotenv import load_dotenv 3 | from pathlib import Path 4 | 5 | # load local copy of libraries 6 | sys.path.append(str( Path(Path(__file__).parents[1] / 'src').resolve() )) 7 | from spaceandtime.spaceandtime import SpaceAndTime 8 | from spaceandtime.spaceandtime import SXTUser 9 | from spaceandtime.sxtkeymanager import SXTKeyManager 10 | from spaceandtime.sxtresource import SXTResource, SXTTable 11 | from spaceandtime.sxtbiscuits import SXTBiscuit 12 | 13 | API_URL = 'https://api.makeinfinite.dev' 14 | 15 | 16 | def test_resource_save_load_bug(): 17 | sxt = SpaceAndTime() 18 | sxt.authenticate() 19 | 20 | tbl = SXTTable('SXTTemp.test_save_load', private_key=os.getenv('RESOURCE_PRIVATE_KEY'), SpaceAndTime_parent=sxt) 21 | tbl.add_biscuit('admin', sxt.GRANT.ALL) 22 | tbl.create_ddl = """ 23 | CREATE TABLE {table_name} 24 | ( MyID int 25 | , MyName varchar 26 | , MyNumber int 27 | , Primary Key (MyID) 28 | ) {with_statement} 29 | """ 30 | assert tbl.save() # saved correctly? 31 | tbl2 = SXTTable(from_file = tbl.recommended_filename) 32 | assert tbl2.private_key == tbl.private_key 33 | assert tbl2.create_ddl.strip().split('\n')[0].strip() == tbl.create_ddl.strip().split('\n')[0].strip() 34 | assert tbl2.table_name == tbl.table_name 35 | 36 | 37 | def test_resource_methods(): 38 | keys = SXTKeyManager(new_keypair=True) 39 | rs = SXTResource('Test') 40 | userA = SXTUser(user_id='A', user_private_key=keys.private_key, api_key='') 41 | userB = SXTUser(user_id='B', user_private_key='', api_key='') 42 | userE = SXTUser(user_id='E', api_key='') 43 | userE.key_manager = SXTKeyManager() 44 | userO = object() 45 | userS = 'just a string, man' 46 | userK = SXTUser(api_key='sxt_apikey123') 47 | userRS = SXTUser(user_id='RS', user_private_key=keys.private_key) 48 | rs.user = userRS 49 | 50 | assert rs.get_first_valid_user(userO, userS, userE, userA, userB) == userA 51 | assert rs.get_first_valid_user(userS, userB, userA, userE) == userA 52 | assert rs.get_first_valid_user(userA, userB, userO, userS) == userA 53 | assert rs.get_first_valid_user(userS, userRS, userB, userA, userO) == userRS 54 | assert rs.get_first_valid_user() == userRS 55 | 56 | assert rs.get_first_valid_user(userE, userS) == userRS 57 | rs.user = userO 58 | assert rs.get_first_valid_user(userE, userS) == userE 59 | assert rs.get_first_valid_user(userS, userO) == None 60 | 61 | 62 | def test_inserts_deletes_updates(): 63 | sxt = SpaceAndTime() 64 | sxt.authenticate() 65 | 66 | tbl = SXTTable(name='SXTTemp.Test_DML1', from_file='./.env', SpaceAndTime_parent=sxt) 67 | tbl.create_ddl = """ 68 | CREATE TABLE {table_name} 69 | ( MyID int 70 | , MyName varchar 71 | , MyNumber int 72 | , Primary Key (MyID) 73 | ) {with_statement} 74 | """ 75 | tbl.add_biscuit('admin',sxt.GRANT.ALL) 76 | if not tbl.exists: 77 | tbl.create() 78 | else: 79 | tbl.delete(where='1=1') 80 | 81 | data_in = [ {'MyID':1, 'MyName':'Abby', 'MyNumber':6} 82 | ,{'MyID':2, 'MyName':'Bob', 'MyNumber':6} 83 | ,{'MyID':3, 'MyName':'Chuck', 'MyNumber':6} 84 | ,{'MyID':4, 'MyName':'Daria', 'MyNumber':6} 85 | ] 86 | tbl.insert.with_list_of_dicts(data_in) 87 | success, data = tbl.select() 88 | assert success 89 | assert [r['MYNUMBER'] for r in data] == [6, 6, 6, 6] 90 | pass 91 | 92 | tbl.update.with_sqltext('update {table_name} set MyNumber = 7') 93 | success, data = tbl.select() 94 | assert success 95 | assert [r['MYNUMBER'] for r in data] == [7, 7, 7, 7] 96 | 97 | update_data = [{'MyID':r['MYID'], 'MyNumber':r['MYID']} for r in data] 98 | tbl.update.with_list_of_dicts('MyID',update_data) 99 | success, data = tbl.select() 100 | assert success 101 | assert sorted([r['MYNUMBER'] for r in data]) == [1, 2, 3, 4] 102 | 103 | update_data = [{'MyID':r['MYID'], 'MyNumber':r['MYID']+10} for r in data] 104 | tbl.update.with_list_of_dicts('MyID', update_data) 105 | success, data = tbl.select() 106 | assert success 107 | assert sorted([r['MYNUMBER'] for r in data]) == [11, 12, 13, 14] 108 | 109 | 110 | # error states: 111 | update_data = [{'MyNumber':r['MYID']+20} for r in data] # no PK 112 | success, result = tbl.update.with_list_of_dicts('MyID', update_data) 113 | assert not success 114 | assert result['rows'] == 4 115 | assert result['errors'] == 4 116 | assert result['successes'] == 0 117 | 118 | update_data = [{'MyID':r['MYID']} for r in data] # only PK 119 | success, result = tbl.update.with_list_of_dicts('MyID', update_data) 120 | assert not success 121 | assert result['rows'] == 4 122 | assert result['errors'] == 4 123 | assert result['successes'] == 0 124 | 125 | update_data = [{'MyID':r['MYID'], 'MyNumber':r['MYID']*11} for r in data] # missing record 126 | update_data.append({'MyID':5, 'MyNumber':55}) 127 | success, result = tbl.update.with_list_of_dicts('MyID', update_data) 128 | assert not success 129 | assert result['rows'] == 5 130 | assert result['errors'] == 1 131 | assert result['successes'] == 4 132 | 133 | update_data = [{'MyID':r['MYID'], 'MyNumber':r['MYID']*11} for r in data] # missing record 134 | update_data.append({'MyID':5, 'MyNumber':55}) 135 | success, result = tbl.update.with_list_of_dicts('MyID', update_data, upsert = True) 136 | assert success 137 | assert result['rows'] == 5 138 | assert result['errors'] == 0 139 | assert result['successes'] == 5 140 | 141 | success, results = tbl.delete(where = 'MyID in(1,3,5)') 142 | assert success 143 | assert results == [{'UPDATED': 3}] 144 | 145 | success, results = tbl.delete(where = 'MyID = 12345') 146 | assert success 147 | assert results == [{'UPDATED': 0}] 148 | 149 | success, results = tbl.delete(where = '1=1') # empty table 150 | assert success 151 | success, results = tbl.insert.list_of_dicts_batch(data_in) 152 | assert success 153 | success, results = tbl.select(f'select count(*) from {tbl.table_name}') 154 | assert success 155 | assert results == [{'COUNT': 4}] 156 | 157 | if tbl.exists: 158 | success, result = tbl.drop() 159 | assert success 160 | 161 | pass 162 | 163 | 164 | def test_table_discovery(): 165 | sxt = SpaceAndTime() 166 | sxt.authenticate() 167 | tbl = SXTTable('Polygon.Blocks', SpaceAndTime_parent=sxt) 168 | 169 | assert tbl.columns != {} 170 | 171 | 172 | if __name__ == '__main__': 173 | # test_table_discovery() 174 | # test_resource_save_load_bug() 175 | # test_resource_methods() 176 | # test_inserts_deletes_updates() 177 | pass -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ## Python Space and Time SDK 6 | 7 | 8 | 9 | Python SDK for Space and Time Gateway (python version >= 3.11) 10 | 11 | 12 | 13 | ## Installation Instructions 14 | 15 | 16 | 17 | _Note: The recommended approach to storing keys is using an `.env` file. 18 | For more information, please see: https://docs.spaceandtime.io/docs/dotenv_ 19 | 20 | 21 | 22 | ```sh 23 | pip install spaceandtime 24 | ``` 25 | 26 | 27 | 28 | 29 | ### Getting Started 30 | 31 | ```python 32 | # Initializing the Space and Time usage. 33 | from spaceandtime import SpaceAndTime 34 | 35 | sxt = SpaceAndTime() 36 | sxt.authenticate() 37 | 38 | success, rows = sxt.execute_query( 39 | 'select * from POLYGON.BLOCKS limit 5') 40 | print( rows ) 41 | ``` 42 | 43 | The authentication without arguments will seek out a default `.env` file and use credentials found there. It also supports passing in a specific ```filepath.env``` or simply supplying ```user_id``` and ```private_key```. 44 | 45 | The generated ``access_token`` is valid for 25 minutes and the ``refresh_token`` for 30 minutes. 46 | 47 | There are a number of convenience features in the SDK for handling return data sets. By default, data sets are returned as a list-of-dictionaries, however can be easily turned into other formats, such as CSV. 48 | 49 | ```python 50 | # use triple-quotes to insert more complicated sql: 51 | success, rows = sxt.execute_query(""" 52 | SELECT 53 | substr(time_stamp,1,7) AS YrMth 54 | ,count(*) AS block_count 55 | FROM polygon.blocks 56 | GROUP BY YrMth 57 | ORDER BY 1 desc """ ) 58 | 59 | # print results as CSV 60 | print( sxt.json_to_csv(rows) ) 61 | ``` 62 | 63 | More data transforms will be added over time. 64 | 65 | ### SXTUser Object 66 | 67 | All SQL requests are handled by an authenticated user object. The ```sxt``` wrapper object contains a 'default user' object for simplicity, managing and authenticating as needed. It is however exposed if needed: 68 | 69 | ```python 70 | print( sxt.user ) 71 | ``` 72 | 73 | You can also manage users directly. This allows you to load and authenticate multiple users at a time, in case your application needs to manage several accounts. 74 | 75 | _**All interaction with the network requires an authenticated user.**_ 76 | 77 | The user object owns the authenticated connection to the network, so all requests are submitted by a user object. 78 | 79 | ```python 80 | # Multiple Users 81 | from spaceandtime import SXTUser 82 | 83 | suzy = SXTUser('./users/suzy.env', authenticate=True) 84 | 85 | bill = SXTUser() 86 | bill.load() # defaults to "./.env" 87 | bill.authenticate() 88 | 89 | # new user 90 | pat = SXTUser(user_id='pat') 91 | pat.new_keypair() 92 | pat.api_url = suzy.api_url 93 | pat.save() # <-- Important! don't lose keys! 94 | pat.authenticate() 95 | ``` 96 | 97 | There is also some capability to administer your subscription using the SDK. This capability will expand more over time. 98 | 99 | ```python 100 | # suzy invites pat to her subcription: 101 | if suzy.user_type in ['owner','admin']: 102 | joincode = suzy.generate_joincode() 103 | success, results = pat.join_subscription(joincode) 104 | print( results ) 105 | ``` 106 | 107 | 108 | 109 | ### DISCOVERY 110 | 111 | There are several discovery functions that allow insight to the Space and Time network metadata. 112 | 113 | 114 | ```python 115 | # discovery calls provide network information 116 | success, schemas = sxt.discovery_get_schemas() 117 | 118 | print(f'There are {len(schemas)} schemas currently on the network.') 119 | print(schemas) 120 | ``` 121 | 122 | 123 | ### Creating Tables 124 | 125 | The SDK abstracts away complexity from making a new table into a Table object. This object contains all needed components to be self-sufficient _EXCEPT_ for an authenticated user object, which is required to submit the table creation to the network. 126 | 127 | ```python 128 | # Create a table 129 | from spaceandtime import SXTTable, SXTTableAccessType 130 | 131 | tableA = SXTTable(name = "SXTTEMP.MyTestTable", 132 | new_keypair = True, 133 | default_user = sxt.user, 134 | logger = sxt.logger, 135 | access_type = SXTTableAccessType.PERMISSSIONED) 136 | 137 | tableA.create_ddl = """ 138 | CREATE TABLE {table_name} 139 | ( MyID int 140 | , MyName varchar 141 | , MyDate date 142 | , Primary Key(MyID) 143 | ) {with_statement} 144 | """ 145 | 146 | # create new biscuits for your table 147 | tableA.add_biscuit('read', tableA.PERMISSION.SELECT ) 148 | 149 | tableA.add_biscuit('write', tableA.PERMISSION.SELECT, 150 | tableA.PERMISSION.INSERT, 151 | tableA.PERMISSION.UPDATE, 152 | tableA.PERMISSION.DELETE, 153 | tableA.PERMISSION.MERGE ) 154 | 155 | tableA.add_biscuit('admin', tableA.PERMISSION.ALL ) 156 | 157 | tableA.save() # <-- Important! Don't lose your keys! 158 | 159 | # create with assigned default user 160 | success, results = tableA.create() 161 | ``` 162 | 163 | 164 | The ```table.create_ddl``` and ```table.with_statement``` property will substitute {names} to replace with class values. In the example above, the ```{table_name}``` will be replace with ```tableA.table_name``` and the ```{with_statement}``` will be replaced with a valid WITH statement, itself with substitutions for ```{public_key}``` and ```{access_type}```. 165 | 166 | Note, if the ```{with_statement}``` placeholder is absent, the table object will attempt to add dynamically. 167 | 168 | When adding biscuits, they can either be added as string tokens, or as SXTBiscuit type objects, or as a list of either. 169 | 170 | The ```tableA.save()``` function will save all keys, biscuits, and table attributes to a shell-friendly format, such that you could execute the file in shell and load all values to environment variables, for use in other scripting. For example, 171 | 172 | ```sh 173 | Stephen~$ . ./table--SXTTEMP.New_TableName.sql 174 | Stephen~$ echo $TABLE_NAME 175 | SXTTEMP.New_TableName 176 | ``` 177 | This allows table files created in the python SDK to be used with the SxT CLI. 178 | 179 | 180 | ### Insert, Deletes, and Selects 181 | 182 | There are helper functions to assist quickly adding, removing, and selecting data in the table. Note, these are just helper functions for the specific table object - for more general SQL interface, use the ```sxt.execute_query()``` function. 183 | 184 | ```python 185 | from pprint import pprint # for better viewing of data 186 | 187 | # generate some dummy data 188 | data = [{'MyID':i, 'MyName':chr(64+i), 'MyDate':f'2023-09-0{i}'} for i in list(range(1,10))] 189 | 190 | # insert into the table 191 | tableA.insert.with_list_of_dicts(data) 192 | 193 | # select out again, just for fun 194 | success, rows = tableA.select() 195 | pprint( rows ) 196 | 197 | tableA.delete(where='MyID=6') 198 | 199 | # one less than last time 200 | success, rows = tableA.select() 201 | pprint( rows ) 202 | ``` 203 | 204 | ### Creating Views 205 | 206 | The SXTView object inherits from the same base class as SXTTable, so the two are very similar. One notable difference is a view's need for a biscuit for each table referenced. To add clarity and remind of this requirement, a view contains a ```table_biscuit``` property. Also note that views don't need DML PERMISSIONS, like insert or delete. 207 | 208 | ```python 209 | # create a view 210 | from spaceandtime import SXTView 211 | 212 | viewB = SXTView('SXTTEMP.MyTest_Odds', 213 | default_user=tableA.user, 214 | private_key=tableA.private_key, 215 | logger=tableA.logger) 216 | 217 | viewB.add_biscuit('read', viewB.PERMISSION.SELECT) 218 | viewB.add_biscuit('admin', viewB.PERMISSION.ALL) 219 | viewB.table_biscuit = tableA.get_biscuit('admin') 220 | 221 | viewB.create_ddl = """ 222 | CREATE VIEW {view_name} 223 | {with_statement} 224 | AS 225 | SELECT * 226 | FROM """ + tableA.table_name + """ 227 | WHERE MyID in (1,3,5,7,9) """ 228 | 229 | viewB.save() # <-- Important! don't lose keys! 230 | 231 | success, results = viewB.create() 232 | ``` 233 | 234 | We've used the same private key for the table and the view. This is NOT required, but is convenient if you are building a view atop only one table. 235 | 236 | Each object comes with a pre-built ```recommended_filename``` which acts as the default for ```save()``` and ```load()```. 237 | 238 | ```python 239 | print( tableA.recommended_filename ) 240 | print( viewB.recommended_filename ) 241 | print( suzy.recommended_filename ) 242 | ``` 243 | 244 | Once you're done, it's best practice to clean up. 245 | 246 | ```python 247 | viewB.drop() 248 | tableA.drop() 249 | ``` 250 | -------------------------------------------------------------------------------- /src/spaceandtime/sxtkeymanager.py: -------------------------------------------------------------------------------- 1 | import logging, base64, sys, nacl.signing 2 | from pathlib import Path 3 | from biscuit_auth import KeyPair, PrivateKey 4 | 5 | # done fighting with this, sorry 6 | sxtpypath = str(Path(__file__).parent.resolve()) 7 | if sxtpypath not in sys.path: sys.path.append(sxtpypath) 8 | from sxtexceptions import SxTKeyEncodingError 9 | from sxtenums import SXTKeyEncodings 10 | 11 | 12 | 13 | #### 14 | #### SXT KEY MANAGER 15 | #### 16 | class SXTKeyManager(): 17 | """Class to manage creation and maintenance of keys and biscuits.""" 18 | 19 | biscuits:list = [] 20 | logger:logging.Logger = None 21 | warning_for_biscuit_length = 1800 22 | keychange_callback_func_list = [] 23 | __pv:bytes = bytes(''.encode()) 24 | __pb:bytes = bytes(''.encode()) 25 | __en:SXTKeyEncodings = SXTKeyEncodings.HEX 26 | ENCODINGS = SXTKeyEncodings 27 | 28 | 29 | def __init__(self, private_key:str = None, new_keypair: bool = False, encoding:SXTKeyEncodings = None, keychange_callback_func = None, logger:logging.Logger = None) -> None: 30 | """Class to manage creation and maintenance of keys and biscuits.""" 31 | if logger: 32 | self.logger = logger 33 | else: 34 | self.logger = logging.getLogger() 35 | self.logger.setLevel(logging.INFO) 36 | if len(self.logger.handlers) == 0: 37 | self.logger.addHandler( logging.StreamHandler() ) 38 | self.logger.info('new SXT KeyManager initiated') 39 | self.keychange_callback_func_list = [] 40 | if keychange_callback_func: self.add_keychange_callback(keychange_callback_func) 41 | 42 | if encoding: self.encoding = encoding 43 | if new_keypair: 44 | self.new_keypair() 45 | return None 46 | if private_key: self.private_key = private_key 47 | return None 48 | 49 | 50 | def __str__(self): 51 | flds = self.__keydict__() 52 | flds['private_key'] = flds['private_key'][:6]+'...' 53 | return '\n'.join( [ f'\t{n} = {v}' for n,v in flds.items() ] ) 54 | 55 | def __repr__(self): 56 | return '\n'.join( [ f'\t{n} = {v}' for n,v in self.__keydict__().items() ] ) 57 | 58 | def __keydict__(self, keychanged:str = None) -> dict: 59 | rtn = {'private_key': self.private_key, 60 | 'public_key': self.public_key, 61 | 'encoding': self.encoding.name } 62 | if keychanged: rtn['key_changed'] = keychanged 63 | return rtn 64 | 65 | def __callback__(self, keychanged:str ) -> None: 66 | for func in self.keychange_callback_func_list: 67 | func( self.__keydict__(keychanged) ) 68 | 69 | @property 70 | def private_key(self): 71 | return self.convert_key(self.__pv, SXTKeyEncodings.BYTES, self.encoding) 72 | @private_key.setter 73 | def private_key(self, value): 74 | self.__pv = self.convert_key(value, self.get_encoding_type(value), SXTKeyEncodings.BYTES) if value else '' 75 | self.__pb = '' 76 | self.__callback__('private_key') 77 | self.logger.debug(f'private key updated to { self.__pv[:6] }...') 78 | 79 | @property 80 | def public_key(self): 81 | if self.__pv and len(self.__pb)==0: 82 | kp = KeyPair.from_private_key(PrivateKey.from_hex( self.convert_key(self.__pv, encoding_out = SXTKeyEncodings.HEX))) 83 | self.__pb = bytes(kp.public_key.to_bytes()) 84 | self.__callback__('public_key') 85 | return self.convert_key(self.__pb, encoding_out= self.encoding) 86 | @public_key.setter 87 | def public_key(self, value): 88 | self.__pb = self.convert_key(value, self.get_encoding_type(value), SXTKeyEncodings.BYTES) if value else '' 89 | self.__callback__('public_key') 90 | self.logger.debug(f'public key updated to { self.__pb }...') 91 | 92 | @property 93 | def encoding(self): 94 | return self.__en 95 | @encoding.setter 96 | def encoding(self, value) -> str: 97 | if not value in SXTKeyEncodings: 98 | raise SxTKeyEncodingError("Invalid encoding option, must be a member of SXTKeyEncodings", logger=self.logger) 99 | self.__en = value 100 | 101 | def private_key_to(self, encoding_out: SXTKeyEncodings = SXTKeyEncodings.HEX): 102 | return self.convert_key( key=self.__pv, encoding_out = encoding_out ) 103 | 104 | def public_key_to(self, encoding_out: SXTKeyEncodings = SXTKeyEncodings.HEX): 105 | if len(self.__pb)==0: x = self.public_key # trigger property to refresh 106 | return self.convert_key( key=self.__pb, encoding_out = encoding_out ) 107 | 108 | def get_encoding_type(self, key) -> str: 109 | """-------------------- 110 | Accepts a key str or bytes, and returns the encoding type, [bytes, hex, base64]. 111 | 112 | Args: 113 | key (any): Key to evaluate, as a string or bytes. 114 | 115 | Returns: 116 | str: Encoding type, [bytes, hex, base64] 117 | 118 | Examples: 119 | >>> SXTKeyManager().get_encoding_type("k6G2adpHxohA9sOBwHV8KRE5eDAJ/IEfocv5zkODgjA=") 120 | base64 121 | >>> SXTKeyManager().get_encoding_type("7063e65f0ba0e2aaaeb7d240248be19fea6f68dcccb50e0f2de3e22595f84751") 122 | hex 123 | >>> SXTKeyManager().get_encoding_type(b'\x93\xa1\xb6i\xdaG\xc6\x88@\xf6\xc3\x81\xc0u|)\x119x0\t\xfc\x81\x1f\xa1\xcb\xf9\xceC\x83\x820') 124 | bytes 125 | """ 126 | if type(key) == bytes and len(key) == 32: return SXTKeyEncodings.BYTES 127 | try: 128 | bytes.fromhex(key) 129 | return SXTKeyEncodings.HEX 130 | except: 131 | if type(key) == str and len(key) == 44: return SXTKeyEncodings.BASE64 132 | raise SxTKeyEncodingError(f'Unknown Encoding: {key}', logger=self.logger) 133 | 134 | 135 | def new_keypair(self) -> dict: 136 | """-------------------- 137 | Generate a new ED25519 keypair, set class variables and return dictionary of values. 138 | 139 | Returns: 140 | dict: New keypair values 141 | 142 | Examples: 143 | >>> km = SXTKeyManager(SXTKeyEncodings.BASE64) 144 | >>> km.new_keypair 145 | ['private_key', 'public_key'] 146 | >>> len( km.private_key ) 147 | 64 148 | >>> km.encoding = SXTKeyEncodings.BASE64 149 | >>> len( km.private_key ) 150 | 44 151 | """ 152 | keypair = KeyPair() 153 | self.private_key = bytes(keypair.private_key.to_bytes()) 154 | return { 'private_key': self.private_key 155 | ,'public_key': self.public_key } 156 | 157 | 158 | def convert_key(self, key, encoding_in:SXTKeyEncodings = SXTKeyEncodings.BYTES 159 | , encoding_out:SXTKeyEncodings = SXTKeyEncodings.HEX): 160 | """-------------------- 161 | Converts a key value from one stated format into requested encoding format. 162 | 163 | Args: 164 | key (any): Key value, typically either str [base64, hex] or bytes. 165 | encoding_in (str): Encoding of supplied key, as SXTKeyEncodings 166 | encoding_out (str): Encoding of returned key, as SXTKeyEncodings 167 | 168 | Return: 169 | dict: Converted key the encoding_out encoding. 170 | 171 | Examples: 172 | >>> SXTKeyManager().convert_key('0123456789abcdef', SXTKeyEncodings.HEX, SXTKeyEncodings.BASE64) 173 | ASNFZ4mrze8= 174 | >>> SXTKeyManager().convert_key('ASNFZ4mrze8=', SXTKeyEncodings.BASE64, SXTKeyEncodings.HEX) 175 | 0123456789abcdef 176 | """ 177 | try: 178 | # always take to bytes first 179 | if not key: 180 | key_bytes = bytes(b'') 181 | elif encoding_in == SXTKeyEncodings.BYTES: 182 | key_bytes = bytes(key) 183 | elif encoding_in == SXTKeyEncodings.BASE64: 184 | key_bytes = base64.b64decode(key) 185 | elif encoding_in == SXTKeyEncodings.HEX: 186 | key_bytes = bytes.fromhex(key) 187 | 188 | # format as requested encoding 189 | if encoding_out == SXTKeyEncodings.BYTES: 190 | key_out = key_bytes 191 | elif encoding_out == SXTKeyEncodings.BASE64: 192 | key_out = base64.b64encode(key_bytes).decode('utf-8') 193 | elif encoding_out == SXTKeyEncodings.HEX: 194 | key_out = key_bytes.hex() 195 | 196 | # self.logger.debug(f'Key verified and converted from {encoding_in.name} to {encoding_out.name}.') 197 | return key_out 198 | except Exception as ex: 199 | error = ex 200 | raise SxTKeyEncodingError(f'Error: {error}, going from {encoding_in.name} to {encoding_out.name}', logger=self.logger) 201 | 202 | 203 | def get_KeyPair(self) ->KeyPair: 204 | """Builds and returns a KeyPair object from current private / public key.""" 205 | if not self.__pv: 206 | raise ValueError('Requires valid private_key to be set') 207 | kp = KeyPair.from_private_key( PrivateKey.from_bytes(self.__pv) ) 208 | return kp 209 | 210 | 211 | def sign_message(self, message:str, encoding_out:SXTKeyEncodings = SXTKeyEncodings.HEX): 212 | """-------------------- 213 | Use private key to cryptographically sign and return message. 214 | 215 | Args: 216 | message (str): String message to sign with the class private key and return. 217 | encoding_out (SXTKeyEncodings): Encoding of returned signed message, as SXTKeyEncodings 218 | 219 | Returns: 220 | str | bytes: Signed message, encoded per encoded_out (or class.encoding as default) 221 | 222 | """ 223 | if type(message)!=str: 224 | raise ValueError(f'paramter: "message" must be a string type, not {str(type(message))}') 225 | try: 226 | if not encoding_out: encoding_out = self.encoding 227 | signing_object = nacl.signing.SigningKey(bytes(self.__pv)) 228 | signed_message = signing_object.sign(message.encode('utf-8')) 229 | return self.convert_key(signed_message.signature, SXTKeyEncodings.BYTES, encoding_out) 230 | except Exception as ex: 231 | error = ex 232 | raise SxTKeyEncodingError(error, logger=self.logger) 233 | 234 | 235 | def add_keychange_callback(self, func) -> None: 236 | """Adds a function to a list of functions to call whenever a key (public or private) changes.""" 237 | if type(self.keychange_callback_func_list) != list: self.keychange_callback_func_list = [] 238 | self.keychange_callback_func_list.append(func) 239 | 240 | 241 | def clear_keychange_callback(self) -> None: 242 | """Clears all functions from the keychange callback list.""" 243 | self.keychange_callback_func_list = [] 244 | 245 | 246 | -------------------------------------------------------------------------------- /tests/test_spaceandtime.py: -------------------------------------------------------------------------------- 1 | import os, sys, pytest, pandas, random, json 2 | from pathlib import Path 3 | from datetime import datetime 4 | 5 | # load local copy of libraries 6 | sys.path.append(str( Path(Path(__file__).parents[1] / 'src').resolve() )) 7 | from spaceandtime.spaceandtime import SpaceAndTime 8 | from spaceandtime.spaceandtime import SXTUser 9 | from spaceandtime.sxtkeymanager import SXTKeyManager 10 | from spaceandtime.sxtbiscuits import SXTBiscuit 11 | from spaceandtime.sxtexceptions import * # only contains exceptions prefixed with "SXT" 12 | API_URL = 'https://api.makeinfinite.dev' 13 | 14 | def setup_debug_logger(): 15 | import logging 16 | logfile = Path(Path(__file__).resolve().parent / 'logs'/ f"pytest_debug_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.log") 17 | logger = logging.getLogger() 18 | logger.setLevel(logging.DEBUG) 19 | 20 | if len(logger.handlers) == 0: 21 | formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') 22 | formatter.default_time_format = '%Y-%m-%d %H:%M:%S' 23 | formatter.default_msec_format = '%s.%03d' 24 | # file handler: 25 | file_handler = logging.FileHandler(logfile) 26 | file_handler.setFormatter(formatter) 27 | logger.addHandler(file_handler) 28 | # console handler 29 | console_handler = logging.StreamHandler() 30 | console_handler.setFormatter(formatter) 31 | logger.addHandler(console_handler) 32 | return logger 33 | 34 | mylogger = setup_debug_logger() 35 | 36 | def test_sxt_addfilehandler(): 37 | logfilepath = Path(Path(__file__).parent / 'logs' / 'test_sxt_addfilehandler.log') 38 | sxt = SpaceAndTime() 39 | 40 | for test in [logfilepath, str(logfilepath)]: 41 | logfilepath.unlink(missing_ok=True) 42 | assert not logfilepath.exists() 43 | sxt.logger_addFileHandler(test) 44 | sxt.logger.info('test message') 45 | assert logfilepath.exists() 46 | sxt.logger.handlers.clear() 47 | 48 | 49 | 50 | def test_sxt_exceptions(): 51 | mylogger.info(f'\n\ntest_sxt_exceptions\n{"-"*30}') 52 | # test common exceptions 53 | sxt = SpaceAndTime() 54 | sxt.user.user_id = sxt.user.api_key = '' 55 | with pytest.raises(Exception) as e_info: sxt.authenticate() 56 | 57 | sxt = SpaceAndTime() 58 | sxt.user.private_key = sxt.user.api_key = '' 59 | with pytest.raises(Exception) as e_info: sxt.authenticate() 60 | 61 | with pytest.raises(SxTAuthenticationError) as errinfo: raise SxTAuthenticationError('test message: SxTAuthenticationError') 62 | with pytest.raises(SxTQueryError) as errinfo: raise SxTQueryError('test message: SxTQueryError') 63 | with pytest.raises(SxTFileContentError) as errinfo: raise SxTFileContentError('test message: SxTFileContentError') 64 | with pytest.raises(SxTArgumentError) as errinfo: raise SxTArgumentError('test message: SxTArgumentError') 65 | with pytest.raises(SxTKeyEncodingError) as errinfo: raise SxTKeyEncodingError('test message: SxTKeyEncodingError') 66 | with pytest.raises(SxTBiscuitError) as errinfo: raise SxTBiscuitError('test message: SxTBiscuitError') 67 | with pytest.raises(SxTAPINotDefinedError) as errinfo: raise SxTAPINotDefinedError('test message: SxTAPINotDefinedError') 68 | with pytest.raises(SxTAPINotSuccessfulError) as errinfo: raise SxTAPINotSuccessfulError('test message: SxTAPINotSuccessfulError') 69 | 70 | 71 | def test_sxt_wrapper(): 72 | mylogger.info(f'\n\ntest_sxt_wrapper\n{"-"*30}') 73 | # pick up default .env file, with USERID="pySDK_tester" or... testuser_977604126 ? 74 | # note, that specific user must exist in .env this test to succeed. 75 | envfile = Path(Path(__file__).parents[1] / '.env').resolve() 76 | sxt = SpaceAndTime(envfile_filepath = envfile, logger= setup_debug_logger() ) 77 | sxt.user.user_id = 'pySDK_tester' 78 | assert sxt.user.user_id == 'pySDK_tester' 79 | assert sxt.user.public_key == "Lu8fefHsAYxKfj7oaCx+Rtz7eNiPln6xbOxJJo0aIZQ=" 80 | assert sxt.user.private_key[:6] == 'MeaW6J' 81 | 82 | assert len(sxt.access_token) == 0 83 | assert sxt.user.subscription_id == None 84 | assert sxt.user.is_quota_exceeded == None 85 | assert sxt.user.is_restricted == None 86 | assert sxt.user.is_trial == None 87 | sxt.authenticate() 88 | assert len(sxt.access_token) > 0 89 | assert sxt.user.subscription_id != None 90 | assert sxt.user.is_quota_exceeded != None 91 | assert sxt.user.is_restricted != None 92 | assert sxt.user.is_trial != None 93 | assert sxt.access_token[:4] == 'eyJ0' 94 | assert sxt.user.user_id == 'pySDK_tester' 95 | 96 | success, data = sxt.execute_query('Select * from SXTLabs.Singularity limit 1') 97 | assert success 98 | assert data[0]['NAME'] == 'Singularity' 99 | assert type(data) == list 100 | assert type(data[0]) == dict 101 | 102 | 103 | def test_sxt_user(): 104 | mylogger.info(f'\n\ntest_sxt_user\n{"-"*30}') 105 | # pick up default .env file, with USERID="pySDK_tester" 106 | # note, that specific user must be used for this test to succeed. 107 | print('test_sxt_user') 108 | sxt = None 109 | 110 | # UserA -- load .env file 111 | userA = SXTUser(dotenv_file='./.env', api_url=API_URL, logger= setup_debug_logger() ) 112 | assert userA.user_id == 'pySDK_tester' 113 | assert userA.public_key == "Lu8fefHsAYxKfj7oaCx+Rtz7eNiPln6xbOxJJo0aIZQ=" 114 | assert userA.private_key[:6] == 'MeaW6J' 115 | 116 | assert len(userA.access_token) == 0 117 | userA.authenticate() 118 | assert len(userA.access_token) > 0 119 | assert userA.access_token[:4] == 'eyJ0' 120 | 121 | success, data = userA.execute_query("Select Name, 'A' as UserLetter from SXTLabs.Singularity limit 1") 122 | assert success 123 | assert data[0]['NAME'] == 'Singularity' 124 | assert data[0]['USERLETTER'] == 'A' 125 | 126 | 127 | # UserB -- load alternate .env file 128 | userB = SXTUser(dotenv_file='./.env_alt', api_url=API_URL, authenticate=True, logger= setup_debug_logger() ) 129 | # assert userB.user_id == 'pySDK_tester2' 130 | # assert userB.public_key == "Lu8fefHsAYxKfj7oaCx+Rtz7eNiPln6xbOxJJo0aIZQ=" 131 | # assert userB.private_key[:6] == 'MeaW6J' 132 | assert userB.user_id == 'stephen_cli' 133 | assert userB.public_key == "S4HCEEe5Hlp0ePANRkNF7xrb3zasKz87H9QQ5ZcT9fU=" 134 | assert userB.private_key[:6] == 'z0STaV' 135 | 136 | # authenticate flag used in initializer 137 | assert len(userB.access_token) > 0 138 | assert userB.access_token[:4] == 'eyJ0' 139 | 140 | success, data = userB.execute_query("Select Name, 'B' as UserLetter from SXTLabs.Singularity limit 1") 141 | assert success 142 | assert data[0]['NAME'] == 'Singularity' 143 | assert data[0]['USERLETTER'] == 'B' 144 | 145 | 146 | # Alternate querying with different users 147 | success, data = userA.execute_query("Select Name, 'A' as UserLetter from SXTLabs.Singularity limit 1") 148 | assert success 149 | success, data = userB.execute_query("Select Name, 'B' as UserLetter from SXTLabs.Singularity limit 1") 150 | assert success 151 | success, data = userA.execute_query("Select Name, 'A' as UserLetter from SXTLabs.Singularity limit 1") 152 | assert success 153 | success, data = userB.execute_query("Select Name, 'B' as UserLetter from SXTLabs.Singularity limit 1") 154 | assert success 155 | 156 | # assert userA.user_id != userB.user_id 157 | # assert userA.access_token != userB.access_token 158 | 159 | # this is just for backwards compatibility... prefer not to use: 160 | success, data = userA.execute_sql("Select Name, 'A' as UserLetter from SXTLabs.Singularity limit 1") 161 | assert success 162 | assert data[0]['NAME'] == 'Singularity' 163 | assert data[0]['USERLETTER'] == 'A' 164 | 165 | 166 | 167 | def test_sxt_user_2(): 168 | mylogger.info(f'\n\ntest_sxt_user_2\n{"-"*30}') 169 | # pick up default .env file, with USERID="pySDK_tester" 170 | # note, that specific user must be used for this test to succeed. 171 | print('test_sxt_user') 172 | sxt = None 173 | 174 | # UserA -- load .env file 175 | userA = SXTUser(dotenv_file='./.env', api_url=API_URL, logger= setup_debug_logger() ) 176 | assert userA.user_id == 'pySDK_tester' 177 | assert userA.public_key == "Lu8fefHsAYxKfj7oaCx+Rtz7eNiPln6xbOxJJo0aIZQ=" 178 | assert userA.private_key[:6] == 'MeaW6J' 179 | 180 | assert len(userA.access_token) == 0 181 | userA.authenticate() 182 | assert len(userA.access_token) > 0 183 | assert userA.access_token[:4] == 'eyJ0' 184 | 185 | success, data = userA.execute_query("Select Name, 'A' as UserLetter from SXTLabs.Singularity limit 1") 186 | assert success 187 | assert data[0]['NAME'] == 'Singularity' 188 | assert data[0]['USERLETTER'] == 'A' 189 | 190 | 191 | # UserB -- load alternate .env file 192 | userB = userA # SXTUser(dotenv_file='./.env_alt', api_url=API_URL, authenticate=True, logger= setup_debug_logger() ) 193 | # assert userB.user_id == 'pySDK_tester2' 194 | assert userB.public_key == "Lu8fefHsAYxKfj7oaCx+Rtz7eNiPln6xbOxJJo0aIZQ=" 195 | assert userB.private_key[:6] == 'MeaW6J' 196 | 197 | # authenticate flag used in initializer 198 | assert len(userB.access_token) > 0 199 | assert userB.access_token[:4] == 'eyJ0' 200 | 201 | success, data = userB.execute_query("Select Name, 'B' as UserLetter from SXTLabs.Singularity limit 1") 202 | assert success 203 | assert data[0]['NAME'] == 'Singularity' 204 | assert data[0]['USERLETTER'] == 'B' 205 | 206 | 207 | # Alternate querying with different users 208 | success, data = userA.execute_query("Select Name, 'A' as UserLetter from SXTLabs.Singularity limit 1") 209 | assert success 210 | success, data = userB.execute_query("Select Name, 'B' as UserLetter from SXTLabs.Singularity limit 1") 211 | assert success 212 | success, data = userA.execute_query("Select Name, 'A' as UserLetter from SXTLabs.Singularity limit 1") 213 | assert success 214 | success, data = userB.execute_query("Select Name, 'B' as UserLetter from SXTLabs.Singularity limit 1") 215 | assert success 216 | 217 | # assert userA.user_id != userB.user_id 218 | # assert userA.access_token != userB.access_token 219 | 220 | # this is just for backwards compatibility... prefer not to use: 221 | success, data = userA.execute_sql("Select Name, 'A' as UserLetter from SXTLabs.Singularity limit 1") 222 | assert success 223 | assert data[0]['NAME'] == 'Singularity' 224 | assert data[0]['USERLETTER'] == 'A' 225 | 226 | 227 | 228 | def test_execute_query(): 229 | mylogger.info(f'\n\ntest_execute_query\n{"-"*30}') 230 | sxt = SpaceAndTime() 231 | sxt.authenticate() 232 | success, data = sxt.execute_query('Select * from SXTLabs.Singularity limit 1') 233 | assert success 234 | assert data[0]['NAME'] == 'Singularity' 235 | assert type(data) == list 236 | assert type(data[0]) == dict 237 | 238 | success, data = sxt.execute_query('Select * from SXTLabs.Singularity limit 1', 239 | sql_type=sxt.SQLTYPE.DQL, resources=['SXTLabs.Singularity'], 240 | output_format = sxt.OUTPUT_FORMAT.PARQUET ) 241 | assert success 242 | assert type(data) == bytes 243 | 244 | success, data = sxt.execute_query('Select * from SXTLabs.Singularity limit 1', 245 | sql_type=sxt.SQLTYPE.DQL, resources=['SXTLabs.Singularity'], 246 | output_format = sxt.OUTPUT_FORMAT.DATAFRAME ) 247 | assert success 248 | assert type(data) == pandas.DataFrame 249 | 250 | success, data = sxt.execute_query('Select * from SXTLabs.Singularity limit 1', 251 | sql_type=sxt.SQLTYPE.DQL, resources=['SXTLabs.Singularity'], 252 | output_format = sxt.OUTPUT_FORMAT.CSV ) 253 | assert success 254 | assert type(data) == list 255 | assert type(data[0]) == str # header 256 | assert type(data[1]) == str # data 257 | assert len(data) == 2 # header + 1 data row 258 | assert data[1].count(',') > 3 259 | 260 | 261 | def test_discovery(): 262 | mylogger.info(f'\n\ntest_discovery\n{"-"*30}') 263 | sxt = SpaceAndTime() 264 | sxt.authenticate() 265 | 266 | # Schemas 267 | success, schemas = sxt.discovery_get_schemas(return_as=list) 268 | assert success 269 | assert type(schemas) == list 270 | assert 'ETHEREUM' in schemas 271 | assert 'POLYGON' in schemas # SXTDemo currently removed 272 | assert 'SXTLABS' in schemas 273 | 274 | success, schemas = sxt.discovery_get_schemas(return_as=dict) 275 | assert success 276 | assert type(schemas) == dict 277 | 278 | success, schemas = sxt.discovery_get_schemas(return_as=str) 279 | assert success 280 | assert type(schemas) == str 281 | assert 'POLYGON,' in schemas 282 | assert schemas.count(',') >= 10 283 | 284 | success, all_schemas = sxt.discovery_get_schemas(scope = sxt.DISCOVERY_SCOPE.ALL) 285 | assert success 286 | success, sub_schemas = sxt.discovery_get_schemas(scope = sxt.DISCOVERY_SCOPE.SUBSCRIPTION) 287 | assert success 288 | assert len(all_schemas) > len(sub_schemas) 289 | 290 | # Tables 291 | success, tables = sxt.discovery_get_tables('SXTLabs', scope = sxt.DISCOVERY_SCOPE.SUBSCRIPTION, return_as=list) 292 | assert success 293 | assert 'SXTLABS.CRM_ACCOUNTS' in tables 294 | assert len(tables) >=10 295 | 296 | success, tables = sxt.discovery_get_tables('SXTLabs', search_pattern='CRM_Cosell', scope = sxt.DISCOVERY_SCOPE.SUBSCRIPTION, return_as=list) 297 | assert success 298 | assert 'SXTLABS.CRM_COSELL_AGREEMENTS' in tables 299 | assert len(tables) <=10 300 | 301 | # Columns 302 | success, columns = sxt.discovery_get_table_columns('POLYGON', 'BLOCKS', return_as=list) 303 | assert success 304 | assert 'TIME_STAMP' in columns 305 | assert len(columns) > 5 306 | 307 | success, columns = sxt.discovery_get_table_columns('POLYGON', 'BLOCKS', search_pattern='BLOCK', return_as=dict) 308 | assert success 309 | assert 'BLOCK_NUMBER' in columns.keys() 310 | assert len(columns) < 5 311 | 312 | success, columns = sxt.discovery_get_table_columns('SXTLABS', 'CRM_ACCOUNTS') # defaults to dict 313 | assert success 314 | assert 'CREATED_TIME' in columns.keys() 315 | assert 'ACCOUNT_NAME' in columns.keys() 316 | assert len(columns) > 15 317 | 318 | success, views = sxt.discovery_get_views('SXTLabs', scope = sxt.DISCOVERY_SCOPE.ALL, return_as=list) 319 | assert success 320 | assert len(views) > 0 321 | assert type(views[0]) == str 322 | 323 | success, views = sxt.discovery_get_views('SXTLabs', return_as=json) 324 | assert success 325 | assert len(views) > 0 326 | assert type(views) == str 327 | jsonreturn = json.loads(views) 328 | assert type(jsonreturn) == dict 329 | assert jsonreturn[list(jsonreturn.keys())[0]]['schema'].upper() == 'SXTLABS' 330 | 331 | 332 | if __name__ == '__main__': 333 | # test_sxt_addfilehandler() 334 | # test_sxt_exceptions() 335 | # test_sxt_wrapper() 336 | # test_sxt_user_2() 337 | # test_sxt_user() 338 | # test_execute_query() 339 | # test_discovery() 340 | 341 | # logger = setup_debug_logger() 342 | # logger.info('\n\nDone!!!') 343 | pass -------------------------------------------------------------------------------- /src/spaceandtime/sxtbiscuits.py: -------------------------------------------------------------------------------- 1 | import logging, json, sys 2 | from pathlib import Path 3 | from datetime import datetime 4 | from biscuit_auth import KeyPair, PrivateKey, PublicKey, Authorizer, Biscuit, BiscuitBuilder, BlockBuilder, Rule, DataLogError 5 | 6 | # done fighting with this, sorry 7 | sxtpypath = str(Path(__file__).parent.resolve()) 8 | if sxtpypath not in sys.path: sys.path.append(sxtpypath) 9 | from sxtexceptions import SxTArgumentError, SxTFileContentError, SxTBiscuitError, SxTKeyEncodingError 10 | from sxtenums import SXTPermission, SXTKeyEncodings 11 | from sxtkeymanager import SXTKeyManager 12 | 13 | 14 | 15 | class SXTBiscuit(): 16 | """Definition of a single biscuit.""" 17 | 18 | logger: logging.Logger = None 19 | domain: str = 'sxt' 20 | name: str = 'biscuit_name' 21 | key_manager: SXTKeyManager = None 22 | GRANT = SXTPermission 23 | ENCODINGS = SXTKeyEncodings 24 | __cap:dict = {'schema.resource':['permission1', 'permission2']} 25 | __bt: str = '' 26 | __lastresource: str = '' 27 | __manualtoken:bool = False 28 | __parentbiscuit__:bool = False 29 | __defaultresource__:str = '' 30 | 31 | def __init__(self, name:str = '', private_key: str = None, new_keypair: bool = False, 32 | from_file: Path = None, logger:logging.Logger = None, 33 | biscuit_token:str = None, default_resource:str = None) -> None: 34 | if logger: 35 | self.logger = logger 36 | else: 37 | self.logger = logging.getLogger() 38 | self.logger.setLevel(logging.INFO) 39 | if len(self.logger.handlers) == 0: 40 | self.logger.addHandler( logging.StreamHandler() ) 41 | self.logger.info('-'*30 + '\nNew SXT Biscuit initiated') 42 | self.key_manager = SXTKeyManager(logger=self.logger, encoding=SXTKeyEncodings.BASE64) 43 | if new_keypair: self.key_manager.new_keypair() 44 | if private_key: self.private_key = private_key 45 | self.__cap = {} 46 | if name: self.name = name 47 | if default_resource: self.__defaultresource__ = default_resource 48 | if from_file and Path(from_file).exists: self.load(from_file) 49 | if biscuit_token: 50 | self.__manualtoken = True 51 | self.__bt = biscuit_token 52 | self.logger.info('manual biscuit token accepted as-is, not calculated or verified.') 53 | 54 | 55 | def __str__(self): 56 | return '\n'.join([f"{str(n).rjust(25)}: {v}" for n,v in dict(self.to_json(True, True)).items()]) 57 | 58 | def __repr__(self): 59 | return '\n'.join([f"{str(n).rjust(25)}: {v}" for n,v in dict(self.to_json(False, True)).items()]) 60 | 61 | def __len__(self): 62 | return 1 63 | 64 | @property 65 | def biscuit_text(self): 66 | b = [] 67 | for resource, permissions in self.__cap.items(): 68 | for permission in sorted(permissions): 69 | b.append(f'{self.domain}:capability("{permission}", "{str(resource).lower()}");') 70 | return '\n'.join(b) 71 | 72 | @property 73 | def biscuit_token(self) ->str: 74 | if self.__manualtoken: return self.__bt 75 | if not self.private_key or not self.biscuit_text: 76 | self.__bt = '' 77 | return '' 78 | if not self.__bt: self.__bt = self.regenerate_biscuit_token() 79 | return self.__bt 80 | 81 | @property 82 | def biscuit_json(self) -> dict: 83 | return self.__cap 84 | 85 | @property 86 | def private_key(self) ->str : 87 | return self.key_manager.private_key 88 | @private_key.setter 89 | def private_key(self, value): 90 | self.key_manager.private_key = value 91 | self.__bt = '' 92 | 93 | @property 94 | def public_key(self) ->str : 95 | return self.key_manager.public_key 96 | @public_key.setter 97 | def public_key(self, value): 98 | self.key_manager.public_key = value 99 | 100 | @property 101 | def encoding(self) ->str : 102 | return self.key_manager.encoding 103 | @encoding.setter 104 | def encoding(self, value): 105 | self.key_manager.encoding = value 106 | 107 | @property 108 | def default_resource(self) ->str: 109 | return str(self.__defaultresource__) 110 | @default_resource.setter 111 | def default_resource(self, value): 112 | if type(value)==str: 113 | self.__defaultresource__ = value 114 | else: 115 | raise ValueError('default_resource must be set to a string.') 116 | 117 | 118 | def new_keypair(self) -> dict: 119 | return self.key_manager.new_keypair() 120 | 121 | 122 | def regenerate_biscuit_token(self) -> dict: 123 | """-------------------- 124 | Regenerates the biscuit_token from class.biscuit_text and class.private_key. 125 | 126 | For object consistency, this only leverages the class objects, so there are no arguments. 127 | To build the biscuit_text, clear_capabilities() and then add_capability(), or if you want to import 128 | an existing datalog file, you can load capabilities_from_text(). This function will error without 129 | a valid private_key and biscuit_text. 130 | 131 | Args: 132 | None 133 | 134 | Returns: 135 | str: biscuit_token in base64 format. 136 | """ 137 | if not self.private_key: 138 | raise SxTArgumentError("Private Key is required to create a biscuit", logger=self.logger) 139 | 140 | biscuit_text = self.biscuit_text 141 | if not biscuit_text: 142 | raise SxTArgumentError('Biscuit_Text is required to create a biscuit. Try to add_capability() and inspect biscuit_text to verify.', logger=self.logger) 143 | 144 | try: 145 | private_key_obj = PrivateKey.from_hex(self.key_manager.private_key_to(SXTKeyEncodings.HEX)) 146 | biscuit = BiscuitBuilder(self.biscuit_text).build(private_key_obj) 147 | return biscuit.to_base64() 148 | except DataLogError as ex: 149 | errmsg = ex 150 | raise SxTBiscuitError(errmsg, logger=self.logger) 151 | 152 | 153 | def validate_biscuit(self, biscuit_base64:str, public_key = None) -> str: 154 | if not public_key: public_key = self.public_key 155 | public_key = self.convert_key(public_key, self.get_encoding_type(public_key), SXTKeyEncodings.HEX ) 156 | try: 157 | return Biscuit.from_base64( data=biscuit_base64, root=PublicKey.from_hex( public_key )) 158 | except Exception as ex: 159 | self.logger.error(ex) 160 | raise SxTBiscuitError('Biscuit not validated with Public Key', logger=self.logger) 161 | 162 | 163 | def add_capability(self, resource:str, *permissions): 164 | """-------------------- 165 | Adds a capability to the existing biscuit structure. 166 | 167 | Args: 168 | resource (str): Resource (Schema.Resource) to which permissions are applied. 169 | permission (*): Any number of SXTPermission enums to GRANT to the resource. 170 | 171 | Returns: 172 | int: Number of items added (excluding duplicates) 173 | 174 | Examples: 175 | >>> bb = SXTBiscuitBuilder() 176 | >>> bb.add_capability("Schema.TableA", "SELECT") 177 | 1 178 | >>> bb.add_capability("Schema.TableA", "INSERT") 179 | True 180 | >>> bb.add_capability("Schema.TableA", "INSERT") 181 | False 182 | """ 183 | if not resource: resource = self.__defaultresource__ 184 | if not resource: resource = self.__lastresource 185 | if not resource: raise KeyError('must define a resource (schema.object) on which to assign permissions') 186 | self.__lastresource = resource 187 | if resource not in self.__cap: self.__cap[resource] = [] 188 | if 'ALL' in self.__cap[resource] or '*' in self.__cap[resource]: 189 | self.logger.warning('Cannot add other permissions to a biscuit containing ALL permissions. Request disregarded.') 190 | return self.__isall__(resource) 191 | initial_count = len(self.__cap[resource]) 192 | process_count = 0 193 | final_permissions = [] 194 | for permission in permissions: 195 | process_count += 1 196 | if type(permission) == list: 197 | if 'ALL' in permission: return self.__isall__(resource) 198 | final_permissions += list(permission) 199 | process_count += len(list(permission))-1 200 | else: 201 | if permission.name == 'ALL': return self.__isall__(resource) 202 | final_permissions.append(permission) 203 | self.__cap[resource] += [p.value for p in final_permissions] 204 | self.__cap[resource] = list(set(self.__cap[resource])) 205 | self.__bt = '' 206 | added_count = len(self.__cap[resource]) - initial_count 207 | self.logger.info(f'Added {added_count} permissions, from total {process_count} submitted ({process_count - added_count} duplicates)') 208 | return added_count 209 | 210 | def __isall__(self, resource): 211 | total_count = len(self.__cap[resource]) 212 | self.__cap[resource] = ['*'] 213 | self.logger.info(f'Added ALL permissions, replacing a total of {total_count} other permissions.') 214 | return 1 215 | 216 | 217 | def capabilities_from_text(self, biscuit_text:str) -> None: 218 | """-------------------- 219 | Loads text into biscuit capabilities, for example, loading a datalog file directly. 220 | 221 | Args: 222 | biscuit_text (str): Text to compile into capabilities. 223 | 224 | Results: 225 | str: biscuit_text that has been digested and re-processed. 226 | 227 | """ 228 | biscuit_lines = str(biscuit_text).strip().split('\n') 229 | caps = {} 230 | self.logger.debug(f'Translating supplied text into biscuit capabilities...') 231 | for line in biscuit_lines: 232 | if line.strip().startswith(f'{self.domain}:capability'): 233 | c = line.split('"') 234 | if len(c) <5: 235 | raise SxTArgumentError('biscuit_text capabilities must have format domain:capability("PERMISSION", "RESOURCE");') 236 | p = c[1] # permission 237 | r = c[3] # resource 238 | if r not in caps: caps[r] = [] 239 | caps[r].append(p) 240 | for r in list(caps.keys()): 241 | caps[r] = list(set(caps[r])) 242 | self.__cap = caps 243 | self.__bt = '' 244 | self.logger.debug(f'Successfully translated biscuit_text to biscuit capabilities objects.') 245 | return None 246 | 247 | 248 | def capabilities_from_token(self, biscuit_token:str) -> None: 249 | raise NotImplementedError('Not implemented yet. Please check back later.') 250 | 251 | 252 | def clear_capabilities(self): 253 | """Clears all existing capabilities from the biscuit structure""" 254 | self.__cap = {} 255 | self.__bt = '' 256 | self.logger.debug('Clearing all biscuit capabilities') 257 | return None 258 | 259 | 260 | def to_json(self, mask_private_key:bool = True, add_tabs_to_biscuit_text:bool = False): 261 | """Exports content of biscuit to a json format. WARNING, this can include private key.""" 262 | tab = '\t' if add_tabs_to_biscuit_text else '' 263 | rtn = { 'private_key': getattr(self, 'private_key')[:6]+'...' if mask_private_key else getattr(self, 'private_key') 264 | ,'public_key' : getattr(self, 'public_key' ) 265 | ,'biscuit_capabilities' : self.__cap 266 | ,'biscuit_token': self.biscuit_token 267 | ,'biscuit_text': f'\n{tab}' + str(self.biscuit_text).replace('\n',f'\n{tab}') 268 | } 269 | self.logger.debug(f'Translating object data to json') 270 | return dict(rtn) 271 | 272 | 273 | def save(self, filepath: Path = 'biscuits/biscuit_{resource}_{date}_{time}.json', overwrite:bool = False, resource:str = None) -> Path: 274 | """-------------------- 275 | Saves biscuit information to a json file. 276 | 277 | The filepath will accept three different placholder texts: {resource}, {date}, and {time}. 278 | This allows caller to easily create dynamically named biscuit files, reducing the likelihood of 279 | overwriting biscuit files and thus losing keys. It is best practice to leave overwrite to False 280 | and use placeholders to save different files, removing older save files only after validation 281 | the keys are not needed anymore. 282 | 283 | Args: 284 | filepath (Path): Full file path which to save, with placeholders allowed. 285 | overwrite (bool): If True, will overwrite file if exists 286 | resource (str): Optional resource name for placeholder in filepath. Defaults to last resource set in add_capability(). 287 | 288 | Results: 289 | Path: Same as filepath, if successful 290 | """ 291 | # TODO: add string.replace for resource, date, time 292 | filepath = Path(filepath).resolve() 293 | # do placeholder replacements 294 | if not resource: resource = self.__lastresource 295 | date = datetime.now().strftime('%Y%m%d') 296 | time = datetime.now().strftime('%H%M%S') 297 | filepath = Path(str(filepath).replace('{resource}', resource).replace('{date}',date).replace('{time}',time)) 298 | if filepath.exists() and not overwrite: 299 | raise FileExistsError(f'{filepath} already exists. Set overwrite = True to overwrite automatically.') 300 | 301 | filepath.parent.mkdir(parents=True, exist_ok=True) 302 | self.logger.debug(f'Opening file to write: {filepath}...') 303 | with open(filepath, 'w') as fh: 304 | fh.write( json.dumps(self.to_json(False)).replace('\t','') ) 305 | self.logger.debug(f'Data written to file.') 306 | return filepath 307 | 308 | 309 | def load(self, filepath: Path, resource:str = None, date:str = None, time:str = None) -> dict: 310 | """-------------------- 311 | Loads a biscuit from correctly formated JSON file. 312 | 313 | The filepath will accept three different placholder texts: {resource}, {date}, and {time}. 314 | This allows caller to easily create (and load) dynamically named biscuit files, reducing the 315 | likelihood of overwriting biscuit files and thus losing keys. It is best practice to leave 316 | overwrite to False and use placeholders to save different files, removing older save files 317 | only after validation the keys are not needed anymore. 318 | 319 | Args: 320 | filepath (Path): Full file path which to load from 321 | resource (str): Optional resource name for placeholder in filepath. Defaults to last resource set in add_capability(). 322 | date (str): Optional integer-only date (yyyymmdd) for placeholder in file path. Defaults to current date. 323 | time (str): Optional integer-only time (hhmmss) for placeholder in file path. Defaults to current time. 324 | 325 | Results: 326 | Path: Same as filepath, if successful 327 | """ 328 | self.logger.info('Attempting to load biscuit definition from file...') 329 | filepath = Path(filepath).resolve() 330 | # do placeholder replacements 331 | if not resource: resource = self.__lastresource 332 | if not date: date = datetime.now().strftime('%Y%m%d') 333 | if not time: time = datetime.now().strftime('%H%M%S') 334 | filepath = Path(str(filepath).replace('{resource}', resource).replace('{date}',date).replace('{time}',time)) 335 | # TODO: allow option for 'Most Recent" for date and time, which can look thru 336 | # the parent directory and find the file with the most recent {date} / {time}. 337 | # This will hopefully promote behavior of keeping history of keys, in case 338 | # they're needed 339 | 340 | if not filepath.exists: 341 | raise FileNotFoundError(f'{filepath} not found.') 342 | try: 343 | self.logger.debug(f'Opening file: {filepath}...') 344 | with open(filepath, 'r') as fh: 345 | content = json.loads(fh.read()) 346 | if 'private_key' not in content or 'biscuit_text' not in content: 347 | raise SxTFileContentError 348 | except (SxTFileContentError, json.JSONDecodeError): 349 | self.logger.error(f'File not loaded due to missing, malformed content, or simply unable to load JSON: \n{filepath}') 350 | return None 351 | try: 352 | new_key_encoding = self.key_manager.get_encoding_type(content['private_key']) 353 | new_private_key = self.key_manager.convert_key(content['private_key'], new_key_encoding, SXTKeyEncodings.BYTES) 354 | except SxTArgumentError: 355 | return None 356 | 357 | # Assign last, after all validation. public_key, biscuit_text, biscuit_token all recalculate automatically. 358 | self.logger.info(f'File opened and parsed, loading data into current object.') 359 | self.capabilities_from_text(content['biscuit_text']) 360 | self.private_key = new_private_key 361 | return content 362 | -------------------------------------------------------------------------------- /src/spaceandtime/spaceandtime.py: -------------------------------------------------------------------------------- 1 | import logging, random, sys, json 2 | import pandas as pd 3 | from io import StringIO 4 | from datetime import datetime 5 | from pathlib import Path 6 | 7 | # done fighting with this, sorry 8 | sxtpypath = str(Path(__file__).parent.resolve()) 9 | if sxtpypath not in sys.path: sys.path.append(sxtpypath) 10 | from sxtuser import SXTUser 11 | from sxtresource import SXTTable, SXTView 12 | from sxtkeymanager import SXTKeyManager 13 | from sxtenums import * 14 | from sxtexceptions import * 15 | 16 | class SpaceAndTime: 17 | 18 | user: SXTUser = None 19 | application_name: str = 'SxT-SDK' 20 | network_calls_enabled:bool = True 21 | default_local_folder:str = None 22 | envfile_filepath:str = None 23 | start_time: datetime = None 24 | key_manager: SXTKeyManager = None 25 | GRANT = SXTPermission 26 | ENCODINGS = SXTKeyEncodings 27 | SQLTYPE = SXTSqlType 28 | OUTPUT_FORMAT = SXTOutputFormat 29 | TABLE_ACCESS = SXTTableAccessType 30 | DISCOVERY_SCOPE = SXTDiscoveryScope 31 | 32 | 33 | def __init__(self, envfile_filepath=None, api_url=None, 34 | user_id=None, user_private_key=None, 35 | default_local_folder:str = None, 36 | application_name='SxT-SDK', 37 | logger: logging.Logger = None, 38 | api_key:str = None, 39 | authenticate:bool = False): 40 | """Create new instance of Space and Time SDK for Python""" 41 | if logger: 42 | self.logger = logger 43 | else: 44 | self.logger = logging.getLogger() 45 | self.logger.setLevel(logging.INFO) 46 | frmt = logging.Formatter('%(asctime)s %(levelname)-8s %(message)s','%Y-%m-%d_%H:%M:%S') 47 | self.logger.sxtformat = frmt 48 | if len(self.logger.handlers) == 0: 49 | self.logger.addHandler( logging.StreamHandler() ) 50 | self.logger.handlers[0].formatter = frmt 51 | 52 | self.start_time = datetime.now() 53 | self.logger.info('-'*30 + f'\nSpace and Time SDK initiated for {self.application_name} at {self.start_time.strftime("%Y-%m-%d %H:%M:%S")}') 54 | 55 | if application_name: self.application_name = application_name 56 | self.default_local_folder = default_local_folder if default_local_folder else Path('.').resolve() 57 | self.envfile_filepath = envfile_filepath if envfile_filepath else self.default_local_folder 58 | 59 | self.user = SXTUser(dotenv_file=envfile_filepath, api_url=api_url, user_id=user_id, user_private_key=user_private_key, logger=self.logger, api_key=api_key) 60 | self.key_manager = self.user.key_manager 61 | 62 | if authenticate: self.authenticate() 63 | return None 64 | 65 | @property 66 | def access_token(self) -> str: 67 | return self.user.access_token 68 | 69 | @property 70 | def refresh_token(self) -> str: 71 | return self.user.refresh_token 72 | 73 | @property 74 | def api_key(self) -> str: 75 | return self.user.api_key 76 | @api_key.setter 77 | def api_key(self, value:str): 78 | self.user.api_key = value 79 | 80 | def logger_addFileHandler(self, file:Path) -> None: 81 | """Adds a logging file (handler) location to the default logging object, creating any needed folders and replacing {datetime}, {date}, or {time} with sxt start_time.""" 82 | file = Path( self.__replaceall(str(Path(file).resolve()), replacemap={}) ) 83 | file.parent.mkdir(parents=True, exist_ok=True) 84 | fh = logging.FileHandler(file) 85 | fh.formatter = self.logger.sxtformat 86 | self.logger.addHandler(fh) 87 | 88 | 89 | def authenticate(self, user:SXTUser = None): 90 | """-------------------- 91 | Authenticate user to Space and Time. Uses the default dotenv file to create a default user, if no other is supplied. 92 | 93 | Args: 94 | user (SXTUser): (optional) SXTUser object used to authenticate, and set as default user. Creates new from default dotenv file if omitted. 95 | 96 | Returns: 97 | bool: Success indicator 98 | str: Access Token returned from Space and Time network 99 | 100 | Examples: 101 | >>> sxt = spaceandtime() 102 | >>> success, access_token = sxt.authenticate() 103 | >>> print( success ) 104 | True 105 | >>> print( len(access_token) >= 64 ) 106 | True 107 | 108 | """ 109 | if not user: user = self.user 110 | if self.network_calls_enabled: 111 | success, rtn = user.authenticate() 112 | else: 113 | user.access_token = 'eyJ0eXBlI_this_is_a_pretend_access_token_it_will_not_really_work_4lXUgI5gIdk8T5Rb4Zlx8-Z1rlY-0y4pu5b4lIjh60wQY_g0vkteuQE0Or0cPDbstDnLg8uRpz5dM4GNg7QHYQ' 114 | user.refresh_token = 'eyJ0eXBlI_this_is_a_pretend_refresh_token_it_will_not_really_work_4lXUgI5gIdk8T5Rb4Zlx8-Z1rlY-0y4pu5b4lIjh60wQY_g0vkteuQE0Or0cPDbstDnLg8uRpz5dM4GNg7QHYQ' 115 | success, rtn = (True, user.access_token) 116 | user.base_api.access_token = self.user.access_token 117 | self.logger.info(f'Authentication Success: {success}') 118 | if not success: self.logger.error(f'Authentication error: {str(rtn)}') 119 | return success, rtn 120 | 121 | 122 | def execute_query(self, sql_text:str, sql_type:SXTSqlType = SXTSqlType.DQL, 123 | resources:list = None, user:SXTUser = None, 124 | biscuits:list = None, output_format:SXTOutputFormat = SXTOutputFormat.JSON) -> tuple: 125 | """-------------------- 126 | Execute a query using an authenticated user. If not specified, uses the default user. 127 | 128 | Args: 129 | sql_text (str): SQL query text to execute. Allowed two placeholders: {public_key} which will be replaced with the user.public_key, and {resource} which is replaced with the first element in resource list (resource[0]). 130 | resources (list): (optional) List of Resources ("schema.table_name") in the sql_text. Supplying will optimize performance. If only 1 value, can optionally supply a str. 131 | sql_type (SXTSqlType): (optional) Type of query, DML, DDL, DQL. Supplying will optimize performance. 132 | user (SXTUser): (optional) Authenticated user to use to execute the query. Defaults to default user. 133 | biscuits (list): (optional) List of biscuit tokens for permissioned tables. If only querying public tables, this is not needed. 134 | output_format (SXTOutputFormat): (optional) Output format enum, either JSON or CSV. Defaults to SXTOutputFormat.JSON. 135 | 136 | Returns: 137 | bool: True if success, False if in Error. 138 | list: Rows, either in JSON or CSV format. 139 | 140 | Examples: 141 | >>> from spacenadtime import SpaceAndTime 142 | >>> sxt = SpaceAndTime() 143 | >>> sxt.authenticate() 144 | >>> execute_query('Select 1 as A from SXTDEMO.Singularity') 145 | 1 146 | 147 | """ 148 | if not user: user = self.user 149 | if not resources: resources = [] 150 | if not biscuits: biscuits = [] 151 | rtn = [] 152 | 153 | try: 154 | resources = resources if type(resources)==list else [str(resources)] 155 | sql_text = self.__replaceall(mainstr=sql_text, replacemap={'resource':resources[0] if resources else [] ,'public_key':user.public_key }) 156 | self.logger.info(f'Executing query: \n{sql_text}') 157 | 158 | if self.network_calls_enabled: 159 | if sql_type == SXTSqlType.DDL : 160 | success, rtn = user.base_api.sql_ddl(sql_text=sql_text, biscuits=biscuits, app_name=self.application_name) 161 | 162 | elif sql_type == SXTSqlType.DML and resources: 163 | success, rtn = user.base_api.sql_dml(sql_text=sql_text, biscuits=biscuits, app_name=self.application_name, resources=resources) 164 | 165 | elif sql_type == SXTSqlType.DQL and resources: 166 | success, rtn = user.base_api.sql_dql(sql_text=sql_text, biscuits=biscuits, app_name=self.application_name, resources=resources) 167 | 168 | else: 169 | success, rtn = user.base_api.sql_exec(sql_text=sql_text, biscuits=biscuits, app_name=self.application_name) 170 | else: 171 | success, rtn = (True, [{'col1':'data', 'col2':'data'},{'col1':'data', 'col2':'data'},{'col1':'data', 'col2':'data'}] ) 172 | 173 | if not success: raise SxTQueryError(f'Query Failed: {str(rtn)}', logger=self.logger) 174 | 175 | except SxTQueryError as ex: 176 | self.logger.error(f'Error in query execution: {ex}') 177 | return False, {'error':f'Error in query execution: {ex}'} 178 | 179 | if output_format == SXTOutputFormat.JSON: return True, rtn 180 | if output_format == SXTOutputFormat.CSV: return self.json_to_csv(rtn) 181 | if output_format == SXTOutputFormat.DATAFRAME: return self.json_to_dataframe(rtn) 182 | if output_format == SXTOutputFormat.PARQUET: return self.json_to_parquet(rtn) 183 | return True, rtn 184 | 185 | 186 | def json_to_csv(self, list_of_dicts:list) -> list: 187 | """-------------------- 188 | Takes a list of dictionaries (default return from DQL query) and transforms to a list of CSV rows, preceded with a header row. 189 | 190 | Args: 191 | list_of_dicts (list): A list of dictionary items, i.e., rows of JSON columns. 192 | 193 | Returns: 194 | bool: success flag 195 | list: A list of CSV strings, i.e., rows of CSV values plus a header row (len(list) will always be N+1) 196 | """ 197 | if list_of_dicts == []: return False, [] 198 | try: 199 | rows = [','.join( list(list_of_dicts[0].keys()) )] # headers 200 | for row in list_of_dicts: 201 | rows.append( ','.join([f'"{str(val).replace(chr(34),chr(34)+chr(34))}"' for val in list(row.values())]) ) 202 | self.logger.debug('Query JSON transformed to CSV') 203 | return True, rows 204 | except Exception as ex: 205 | self.logger.error(f'Query JSON could not be transformed to CSV: {ex}') 206 | return False, None 207 | 208 | 209 | def json_to_dataframe(self, list_of_dicts:list) -> pd.DataFrame: 210 | """-------------------- 211 | Takes a list of dictionaries (default return from DQL query) and transforms to a dataframe object. 212 | 213 | Args: 214 | list_of_dicts (list): A list of dictionary items, i.e., rows of JSON columns. 215 | 216 | Returns: 217 | bool: success flag 218 | list: pandas dataframe object. 219 | """ 220 | try: 221 | df = pd.read_json( StringIO(json.dumps(list_of_dicts)) ) 222 | self.logger.debug('Query JSON transformed to DataFrame') 223 | return True, df 224 | except Exception as ex: 225 | self.logger.error(f'Query JSON could not be transformed to DataFrame: {ex}') 226 | return False, None 227 | 228 | 229 | def json_to_parquet(self, list_of_dicts:list) -> bytes: 230 | """-------------------- 231 | Takes a list of dictionaries (default return from DQL query) and transforms to a parquet byte array. 232 | 233 | Args: 234 | list_of_dicts (list): A list of dictionary items, i.e., rows of JSON columns. 235 | 236 | Returns: 237 | bool: success flag 238 | list: parquet formatted binary. 239 | """ 240 | success, df = self.json_to_dataframe(list_of_dicts) 241 | if not success: 242 | self.logger.warning('Query JSON return could not be turned into a DataFrame, and hence, not into a Parquet Binary') 243 | return False, None 244 | try: 245 | pq = df.to_parquet() 246 | self.logger.debug('Query JSON transformed to Parquet Binary') 247 | return True, pq 248 | except Exception as ex: 249 | self.logger.error(f'Query JSON could not be transformed to Parquet Binary: {ex}') 250 | return False, None 251 | 252 | 253 | def __replaceall(self, mainstr:str, replacemap:dict) -> str: 254 | if 'date' not in replacemap.keys(): replacemap['date'] = datetime.now().strftime('%Y%m%d') 255 | if 'time' not in replacemap.keys(): replacemap['time'] = datetime.now().strftime('%H%M%S') 256 | if 'datetime' not in replacemap.keys(): replacemap['datetime'] = datetime.now().strftime('%Y%m%d_%H%M%S') 257 | for findname, replaceval in replacemap.items(): 258 | mainstr = mainstr.replace('{'+str(findname)+'}', str(replaceval)) 259 | return mainstr 260 | 261 | 262 | def discovery_get_schemas(self, scope:SXTDiscoveryScope = SXTDiscoveryScope.ALL, 263 | user:SXTUser = None, 264 | return_as:type = dict) -> tuple: 265 | """-------------------- 266 | Connects to the Space and Time network and returns all available schemas. 267 | 268 | Args: 269 | scope (SXTDiscoveryScope): (optional) Scope of objects to return: All, Public, Subscription, or Private. Defaults to SXTDiscoveryScope.ALL. 270 | user (SXTUser): (optional) Authenticated User object. Uses default user if omitted. 271 | return_as (type): (optional) Python type to return. Currently supports json, dict, list, str. 272 | 273 | Returns: 274 | object: Return type defined with the return_as feature. 275 | """ 276 | if not user: user = self.user 277 | if not scope: scope = SXTDiscoveryScope.ALL 278 | success, response = user.base_api.discovery_get_schemas(scope=scope.name) 279 | if not success: 280 | self.logger.warning("WARNING: base_api.discovery_get_schemas() failed to return Success") 281 | return False, None 282 | 283 | if return_as in [list, str]: 284 | response = sorted([s['schema'] for s in response]) 285 | if return_as == str: response = ', '.join(response) 286 | else: 287 | # all other options are flavors of a dict: 288 | response = {s['schema']:s for s in response} 289 | 290 | # which flavor? 291 | if return_as in [json]: response = json.dumps(response, indent=4) 292 | elif return_as in [dict, list, str]: pass # good as-is 293 | else: 294 | self.logger.warning('Supplied an unsupported return type, only [json, dict, list, str] currently supported. Defaulting to dict.') 295 | return success, response 296 | 297 | 298 | def discovery_get_tables(self, schema:str, 299 | scope:SXTDiscoveryScope = SXTDiscoveryScope.ALL, 300 | user:SXTUser = None, 301 | search_pattern:str = None, 302 | return_as:type = dict) -> tuple: 303 | """-------------------- 304 | Connects to the Space and Time network and returns all available tables within a schema. 305 | 306 | Args: 307 | schema (str): Schema name to search for tables. 308 | scope (SXTDiscoveryScope): (optional) Scope of objects to return: All, Public, Subscription, or Private. Defaults to SXTDiscoveryScope.ALL. 309 | user (SXTUser): (optional) Authenticated User object. Uses default user if omitted. 310 | search_pattern (str): (optional) Tablename pattern to match for inclusion into result set. Defaults to None / all tables. 311 | return_as (type): (optional) Python type to return. Currently supports json, dict, list, str. 312 | 313 | Returns: 314 | object: Return type defined with the return_as feature. 315 | """ 316 | if not user: user = self.user 317 | if not scope: scope = SXTDiscoveryScope.ALL 318 | success, response = user.base_api.discovery_get_tables(scope=scope.name, schema=schema, search_pattern=search_pattern) 319 | if not success: 320 | self.logger.warning("WARNING: base_api.discovery_get_tables() failed to return Success") 321 | return False, None 322 | 323 | if return_as in [list, str]: 324 | response = sorted([ f"{r['schema']}.{r['table']}" for r in response]) 325 | if return_as == str: response = ', '.join(response) 326 | else: 327 | # all other options are flavors of a dict: 328 | response = {f"{r['schema']}.{r['table']}":r for r in response} 329 | 330 | # which flavor? 331 | if return_as in [json]: response = json.dumps(response, indent=4) 332 | elif return_as in [dict, list, str]: pass # good as-is 333 | else: 334 | self.logger.warning('Supplied an unsupported return type, only [json, dict, list, str] currently supported. Defaulting to dict.') 335 | return success, response 336 | 337 | 338 | 339 | def discovery_get_table_columns(self, schema:str, tablename:str, 340 | user:SXTUser = None, 341 | search_pattern:str = None, 342 | return_as:type = dict) -> tuple: 343 | """-------------------- 344 | Connects to the Space and Time network and returns all available columns within a table. 345 | 346 | Args: 347 | schema (str): Schema name containing the below tablename. 348 | tablename (str): Name of table to search metadata for, and return list of column information. 349 | user (SXTUser): (optional) Authenticated User object. Uses default user if omitted. 350 | search_pattern (str): (optional) Tablename pattern to match for inclusion into result set. Defaults to None / all columns. 351 | return_as (type): (optional) Python type to return. Currently supports n, dict, list, str. 352 | 353 | Returns: 354 | object: Return type defined with the return_as feature. 355 | """ 356 | if not user: user = self.user 357 | success, response = user.base_api.discovery_get_columns(schema=schema, table=tablename) 358 | if not success: 359 | self.logger.warning("WARNING: base_api.discovery_get_columns() failed to return Success") 360 | return False, None 361 | # raise SxTAPINotSuccessfulError("base_api.discovery_get_columns() failed to return Success") 362 | if search_pattern: response = [r for r in response if str(search_pattern).lower() in r['column'].lower()] 363 | 364 | # sort by 'position' 365 | response = sorted(response, key=lambda d: d['position']) 366 | 367 | if return_as in [list, str]: 368 | response = ([ f"{r['column']}" for r in response]) 369 | if return_as == str: response = ', '.join(response) 370 | else: 371 | # all other options are flavors of a dict: 372 | response = {r['column']:{n:v for n,v in r.items() if n!='column'} for r in response} 373 | 374 | # which flavor? 375 | if return_as in [json]: response = json.dumps(response, indent=4) 376 | elif return_as in [dict, list, str]: pass # good as-is 377 | else: 378 | self.logger.warning('Supplied an unsupported return type, only [json, dict, list, str] currently supported. Defaulting to dict.') 379 | return success, response 380 | 381 | 382 | 383 | def discovery_get_views(self, schema:str, 384 | scope:SXTDiscoveryScope = SXTDiscoveryScope.ALL, 385 | user:SXTUser = None, 386 | search_pattern:str = None, 387 | return_as:type = dict) -> tuple: 388 | """-------------------- 389 | Connects to the Space and Time network and returns all available views within a schema, along with view text and other metadata. 390 | 391 | Args: 392 | schema (str): Schema name to search for views. 393 | scope (SXTDiscoveryScope): (optional) Scope of objects to return: All, Public, or Subscription. Defaults to SXTDiscoveryScope.ALL. 394 | user (SXTUser): (optional) Authenticated User object. Uses default user if omitted. 395 | search_pattern (str): (optional) Tablename pattern to match for inclusion into result set. Defaults to None / all tables. 396 | return_as (type): (optional) Python type to return. Currently supports json, dict (unabridged), list, str (abridged). 397 | 398 | Returns: 399 | object: Return type defined with the return_as feature. 400 | """ 401 | if not user: user = self.user 402 | if not scope: scope = SXTDiscoveryScope.ALL 403 | success, response = user.base_api.discovery_get_views(schema=schema, scope=scope.name, search_pattern=search_pattern) 404 | if not success: 405 | self.logger.warning("WARNING: base_api.discovery_get_views() failed to return Success") 406 | return False, None 407 | 408 | if return_as in [list, str]: 409 | response = sorted([ f"{r['schema']}.{r['view']}" for r in response]) 410 | if return_as == str: response = ', '.join(response) 411 | else: 412 | # all other options are flavors of a dict: 413 | response = {f"{r['schema']}.{r['view']}":r for r in response} 414 | 415 | # which flavor? 416 | if return_as in [json]: response = json.dumps(response, indent=4) 417 | elif return_as in [dict,list, str]: pass # good as-is 418 | else: 419 | self.logger.warning('Supplied an unsupported return type, only [json, dict, list, str] currently supported. Defaulting to dict.') 420 | return success, response 421 | -------------------------------------------------------------------------------- /src/spaceandtime/sxtuser.py: -------------------------------------------------------------------------------- 1 | import os, logging, datetime, random, sys 2 | from pathlib import Path 3 | from dotenv import load_dotenv 4 | 5 | # done fighting with this, sorry 6 | sxtpypath = str(Path(__file__).parent.resolve()) 7 | if sxtpypath not in sys.path: sys.path.append(sxtpypath) 8 | from sxtexceptions import SxTAuthenticationError, SxTArgumentError 9 | from sxtkeymanager import SXTKeyManager, SXTKeyEncodings 10 | from sxtbaseapi import SXTBaseAPI, SXTApiCallTypes 11 | 12 | 13 | class SXTUser(): 14 | user_id: str = '' 15 | email: str = '' 16 | gateway_password = '' 17 | logger: logging.Logger = None 18 | key_manager: SXTKeyManager = None 19 | ENCODINGS = SXTKeyEncodings 20 | base_api: SXTBaseAPI = None 21 | access_token: str = '' 22 | refresh_token: str = '' 23 | access_token_expire_epoch: int = 0 24 | refresh_token_expire_epoch: int = 0 25 | api_key:str = '' 26 | auto_reauthenticate:bool = False 27 | start_time:datetime.datetime = None 28 | __bs: list = None 29 | __usrtyp__:list = None 30 | __usrinfo__:dict = {} 31 | 32 | def __init__(self, dotenv_file:Path = None, user_id:str = None, 33 | user_private_key:str = None, api_url:str = None, 34 | encoding:SXTKeyEncodings = None, authenticate:bool = False, 35 | application_name:str = None, 36 | logger:logging.Logger = None, 37 | SpaceAndTime_parent:object = None, 38 | api_key:str = None, access_token:str = None, 39 | **kwargs) -> None: 40 | 41 | # start with parent import 42 | if SpaceAndTime_parent: 43 | if not application_name: self.application_name = SpaceAndTime_parent.application_name 44 | if not logger: logger = SpaceAndTime_parent.logger 45 | self.start_time = SpaceAndTime_parent.start_time if SpaceAndTime_parent.start_time else datetime.datetime.now() 46 | else: 47 | self.start_time = datetime.datetime.now() 48 | 49 | if logger: 50 | self.logger = logger 51 | else: 52 | self.logger = logging.getLogger() 53 | self.logger.setLevel(logging.INFO) 54 | if len(self.logger.handlers) == 0: 55 | self.logger.addHandler( logging.StreamHandler() ) 56 | self.logger.debug(f'SXT User instantiating...') 57 | 58 | encoding = encoding if encoding else SXTKeyEncodings.BASE64 59 | self.key_manager = SXTKeyManager(private_key = user_private_key, encoding = encoding, logger=self.logger) 60 | self.base_api = SXTBaseAPI(logger = self.logger) 61 | self.__bs = [] 62 | self.__usrtyp__ = {'type':'', 'timeout':datetime.datetime.now()} 63 | 64 | # from dotenv file, if exists 65 | dotenv_file = Path('./.env') if not dotenv_file and Path('./.env').resolve().exists() else dotenv_file 66 | if dotenv_file: self.load(dotenv_file) 67 | 68 | # overwrite userid, api_url, and private key (and public key, by extension), if supplied 69 | if user_private_key != None: self.private_key = user_private_key 70 | if user_id != None: self.user_id = user_id 71 | if api_url != None: self.base_api.api_url = api_url 72 | if api_key != None: self.api_key = api_key 73 | if access_token != None: self.access_token = access_token 74 | 75 | # get user info from the network and cache (sets __usrinfo__ if malformed) 76 | self.__usrinfo__ = self.get_user_network_info() 77 | 78 | self.logger.info(f'SXT User instantiated: {self.user_id}') 79 | if authenticate: self.authenticate() 80 | 81 | 82 | @property 83 | def private_key(self) ->str : 84 | return self.key_manager.private_key 85 | @private_key.setter 86 | def private_key(self, value): 87 | self.key_manager.private_key = value 88 | 89 | @property 90 | def public_key(self) ->str : 91 | return self.key_manager.public_key 92 | @public_key.setter 93 | def public_key(self, value): 94 | self.key_manager.public_key = value 95 | 96 | @property 97 | def encoding(self) ->str : 98 | return self.key_manager.encoding 99 | @encoding.setter 100 | def encoding(self, value): 101 | self.key_manager.encoding = value 102 | 103 | @property 104 | def api_url(self) -> str: 105 | return self.base_api.api_url 106 | @api_url.setter 107 | def api_url(self, value): 108 | self.base_api.api_url = value 109 | 110 | @property 111 | def access_token_expire_datetime(self) -> datetime.datetime: 112 | return datetime.datetime.fromtimestamp(self.access_token_expire_epoch/1000) 113 | 114 | @property 115 | def refresh_token_expire_datetime(self) -> datetime.datetime: 116 | return datetime.datetime.fromtimestamp(self.refresh_token_expire_epoch/1000) 117 | 118 | @property 119 | def access_expired(self) -> bool: 120 | return datetime.datetime.now() > self.access_token_expire_datetime 121 | 122 | @property 123 | def refresh_expired(self) -> bool: 124 | return datetime.datetime.now() > self.refresh_token_expire_datetime 125 | 126 | @property 127 | def user_type(self) -> str: 128 | if self.__usrtyp__['type'] == '' or self.__usrtyp__['timeout'] <= datetime.datetime.now(): 129 | success, users = self.base_api.subscription_get_users() 130 | if success and self.user_id in users['roleMap']: 131 | self.__usrtyp__['type'] = str(users['roleMap'][self.user_id]).lower() 132 | self.__usrtyp__['timeout'] = datetime.datetime.now() + datetime.timedelta(minutes=15) 133 | return self.__usrtyp__['type'] 134 | else: 135 | return 'disconnected - authenticate to retrieve' 136 | else: 137 | return self.__usrtyp__['type'] 138 | 139 | @property 140 | def recommended_filename(self) -> Path: 141 | filename = f'./users/{self.user_id}.env' 142 | return Path(filename) 143 | 144 | 145 | @property 146 | def exists(self) -> bool: 147 | """Returns whether the user_id exists on the network.""" 148 | success, response = self.base_api.auth_idexists(self.user_id) 149 | return True if str(response).lower() == 'true' else False 150 | 151 | 152 | def get_user_network_info (self) -> dict: 153 | """ 154 | Returns the network information about the user, given the current access token. Will cache results for 2 seconds, 155 | to reduce network calls for repetitive hits (like printing the user object). 156 | """ 157 | CACHE_SECONDS = 2 158 | # if info is malformed, reset 159 | for itm in ['userId', 'subscriptionId', 'restricted', 'quotaExceeded', 'trial', 'last_sync', 'sync_staus']: 160 | if itm not in self.__usrinfo__: 161 | self.__usrinfo__ = { 162 | "userId": None, 163 | "subscriptionId": None, 164 | "restricted": None, 165 | "quotaExceeded": None, 166 | "trial": None, 167 | "last_sync": datetime.datetime.strptime('1970-01-01 00:00:00','%Y-%m-%d %H:%M:%S'), 168 | "sync_staus": None, 169 | "connected_flag": False 170 | } 171 | self.logger.debug(f'get_user_network_info dictionary reset ({self.user_id})') 172 | break 173 | 174 | # if last_sync was less than CACHE_SECONDS ago, return existing data and don't repull 175 | if (self.__usrinfo__['last_sync'] + datetime.timedelta(seconds = CACHE_SECONDS )) > datetime.datetime.now(): 176 | self.logger.debug(f'get_user_network_info request satified by cache ({self.user_id})') 177 | return self.__usrinfo__ 178 | 179 | # if access token is expired, report as disconnected but change nothing 180 | if self.access_expired: 181 | self.__usrinfo__["sync_staus"] = 'disconnected - authenticate to retrieve' 182 | self.__usrinfo__['connected_flag'] = False 183 | self.logger.debug(f'get_user_network_info request aborted due to lack of access token ({self.user_id})') 184 | return self.__usrinfo__ 185 | 186 | # make a call to the network: 187 | success, response = self.base_api.auth_validtoken() 188 | if not success: 189 | self.__usrinfo__["sync_staus"] = 'disconnected - authenticate to retrieve' 190 | self.__usrinfo__['connected_flag'] = False 191 | self.logger.error(f'get_user_network_info request failed due to API call failure ({self.user_id})') 192 | return self.__usrinfo__ 193 | 194 | # loop thru response, validate, and store 195 | issue_found = False 196 | for itm in ['userId', 'subscriptionId', 'restricted', 'quotaExceeded', 'trial']: 197 | if itm in response: self.__usrinfo__[itm] = response[itm] 198 | else: 199 | f'{itm} not returned from API auth/validtoken' 200 | self.__usrinfo__[itm] = None 201 | issue_found = True 202 | self.__usrinfo__['last_sync'] = datetime.datetime.now() 203 | self.__usrinfo__['sync_staus'] = 'synced' if not issue_found else 'synced with issues - missing value from auth/validtoken API' 204 | self.__usrinfo__['connected_flag'] = True 205 | self.logger.debug(f'get_user_network_info request complete and cache updated ({self.user_id})') 206 | if issue_found: self.logger.error(self.__usrinfo__['sync_staus']) 207 | 208 | # set user_id if not set, but do not overwrite 209 | if not self.user_id: self.user_id = self.__usrinfo__['userId'] 210 | return self.__usrinfo__ 211 | 212 | 213 | @property 214 | def subscription_id(self) -> str: 215 | self.get_user_network_info() # has a 2sec cache, so print(user) will only call once 216 | return self.__usrinfo__['subscriptionId'] 217 | 218 | @property 219 | def is_trial(self) -> str: 220 | self.get_user_network_info() # has a 2sec cache, so print(user) will only call once 221 | return self.__usrinfo__['trial'] 222 | 223 | @property 224 | def is_restricted(self) -> str: 225 | self.get_user_network_info() # has a 2sec cache, so print(user) will only call once 226 | return self.__usrinfo__['restricted'] 227 | 228 | @property 229 | def is_quota_exceeded(self) -> str: 230 | self.get_user_network_info() # has a 2sec cache, so print(user) will only call once 231 | return self.__usrinfo__['quotaExceeded'] 232 | 233 | 234 | 235 | 236 | def __str__(self): 237 | fldlist = ['api_url','user_id','exists','api_key','encoding','private_key','public_key'] 238 | if self.__usrinfo__ != {}: fldlist += ['subscription_id', 'is_trial', 'is_restricted', 'is_quota_exceeded'] 239 | flds = {fld: getattr(self, fld) for fld in fldlist} 240 | flds['private_key'] = flds['private_key'][:6]+'...' if flds['private_key'] else '' 241 | flds['api_key'] = flds['api_key'][:10]+'...' if flds['api_key'] else '' 242 | return '\n'.join( [ f'\t{n} = {v}' for n,v in flds.items() ] ) 243 | 244 | 245 | def new_keypair(self): 246 | """-------------------- 247 | Generate a new ED25519 keypair, set class variables and return dictionary of values. 248 | 249 | Returns: 250 | dict: New keypair values 251 | 252 | Examples: 253 | >>> user = SXTUser() 254 | >>> user.new_keypair() 255 | ['private_key', 'public_key'] 256 | >>> len( user.private_key ) 257 | 64 258 | >>> user.encoding = SXTKeyEncodings.BASE64 259 | >>> len( user.private_key ) 260 | 44 261 | """ 262 | return self.key_manager.new_keypair() 263 | 264 | 265 | def load(self, dotenv_file:Path = None): 266 | """Load dotenv (.env) file / environment variables: API_URL, USERID, USER_PUBLIC_KEY, USER_PRIVATE_KEY, optionally USER_JOINCODE, USER_KEY_SCHEME, APP_PREFIX. 267 | 268 | Args: 269 | dotenv_file (Path): Path to .env file. If not set, first default is the file ./.env, second defalut is to load existing environment variables. 270 | 271 | Returns: 272 | None 273 | """ 274 | load_dotenv(dotenv_file, override=True) 275 | self.api_url = os.getenv('API_URL') 276 | 277 | # add user_id from environment (including several options) 278 | for userid_var in ['SXT_USER_ID', 'SXT_USERID', 'USER_ID', 'USERID']: 279 | temp = os.getenv(userid_var) 280 | if temp: 281 | self.user_id = temp 282 | break 283 | 284 | # add user private key from environment (including several options) 285 | for user_privatekey_var in ['SXT_USER_PRIVATE_KEY', 'SXT_USER_PRIVATEKEY', 'USER_PRIVATE_KEY', 'USER_PRIVATEKEY']: 286 | temp = os.getenv(user_privatekey_var) 287 | if temp: 288 | self.private_key = temp 289 | break 290 | 291 | # add user API Key from environment (including several options) 292 | for user_privatekey_var in ['SXT_USER_API_KEY', 'SXT_USER_APIKEY', 'USER_API_KEY', 'USER_APIKEY']: 293 | temp = os.getenv(user_privatekey_var) 294 | if temp: 295 | self.api_key = temp 296 | break 297 | 298 | # TODO: Right now, only ED25519 authentication is supported. Add Eth wallet support, or other future schemes 299 | # self.key_scheme = os.getenv('USER_KEY_SCHEME') 300 | 301 | loc = str(dotenv_file) if dotenv_file and Path(dotenv_file).exists() else 'default .env location' 302 | self.logger.info(f'dotenv loaded') 303 | return None 304 | 305 | 306 | def save(self, dotenv_file:Path = None): 307 | """Save dotenv (.env) file containing variables: API_URL, USERID, USER_PUBLIC_KEY, USER_PRIVATE_KEY, optionally USER_JOINCODE, USER_KEY_SCHEME, APP_PREFIX. 308 | 309 | Args: \n 310 | dotenv_file -- full path to .env file, defaulting to ./users/{user_id}.env if not supplied. Note: to minimize losing keys, overwrites are disallowed. 311 | 312 | Results: \n 313 | None 314 | """ 315 | if not dotenv_file: dotenv_file = self.recommended_filename 316 | dotenv_file = Path(self.replace_all(str(dotenv_file))).resolve() 317 | if dotenv_file.exists(): 318 | self.logger.error(f'File Exists: {dotenv_file}\nTo minimize lost keys, file over-writes are not allowed.') 319 | raise FileExistsError('To minimize lost keys, file over-writes are not allowed.') 320 | 321 | try: 322 | fieldmap = { 'api_url':'API_URL' 323 | ,'user_id':'USERID' 324 | ,'private_key':'USER_PRIVATE_KEY' 325 | ,'public_key':'USER_PUBLIC_KEY' 326 | } 327 | 328 | # build insert string for env file 329 | hdr = '# -------- Below was added by the SxT SDK' 330 | lines = [hdr] 331 | for pyname, envname in fieldmap.items(): 332 | lines.append( f'{envname}="{ getattr(self, pyname) }"' ) 333 | 334 | dotenv_file = Path(dotenv_file) 335 | dotenv_file.parent.mkdir(parents=True, exist_ok=True) 336 | i=0 337 | 338 | if dotenv_file.exists(): 339 | with open(dotenv_file.resolve(), 'r') as fh: # open file 340 | for line in fh.readlines(): # read each line 341 | val = str(line).split('=')[0].strip() # get text before "=" 342 | if val and val != hdr and \ 343 | val not in list(fieldmap.values()): # if text doesn't exist in fieldmap values 344 | lines.insert(i,str(line).strip()) # add it, so it gets written to new file 345 | i+=1 # preserve the original order of the file 346 | 347 | # create (overwrite) file 348 | with open(dotenv_file.resolve(), 'w') as fh: 349 | fh.write( '\n'.join(lines) ) 350 | 351 | self.logger.debug(f'saved dotenv file to: { dotenv_file }') 352 | self.logger.warning('THE SAVED FILE CONTAINS PRIVATE KEYS!') 353 | return None 354 | 355 | except Exception as err: 356 | msg = f'Attempting to write new .env file to {dotenv_file}\n{ str(err) }' 357 | self.logger.error(msg) 358 | raise FileNotFoundError(msg) 359 | 360 | 361 | def replace_all(self, mainstr:str, replace_map:dict = None) -> str: 362 | if not replace_map: replace_map = {'user_id':self.user_id, 'public_key':self.public_key, 'start_time':self.start_time.strftime('%Y-%m-%d %H:%M:%S')} 363 | if 'date' not in replace_map.keys(): replace_map['date'] = int(self.start_time.strftime('%Y%m%d')) 364 | if 'time' not in replace_map.keys(): replace_map['time'] = int(self.start_time.strftime('%H%M%S')) 365 | for findname, replaceval in replace_map.items(): 366 | mainstr = str(mainstr).replace('{'+str(findname)+'}', str(replaceval)) 367 | return mainstr 368 | 369 | 370 | 371 | def __settokens__(self, access_token:str, refresh_token:str, access_token_expire_epoch:int, refresh_token_expire_epoch:int): 372 | self.access_token = access_token 373 | self.refresh_token = refresh_token 374 | self.access_token_expire_epoch = access_token_expire_epoch 375 | self.refresh_token_expire_epoch = refresh_token_expire_epoch 376 | self.base_api.access_token = self.access_token 377 | 378 | 379 | def register_new_user(self, user_id:str = None, email:str = None, join_code:str = None) -> tuple[bool, object]: 380 | """-------------------- 381 | Create a new user on the Space and Time network, then authenticate. 382 | 383 | Args: 384 | user_id (str): User ID to create - if not provided, will default to self.user_id. 385 | email (str): Email address to validate the user id - if not provided, will default to self.email. 386 | join_code (str): (Optional) Join code to create a new user within an existing subscription. 387 | 388 | Returns: 389 | bool: Success flag (True/False) indicating the call worked as expected. 390 | object: Access_Token if successful, otherwise an error object. 391 | """ 392 | if not (self.user_id and self.private_key): 393 | raise SxTArgumentError('Must have valid UserID and Private Key to authenticate.', logger=self.logger) 394 | 395 | try: 396 | if not user_id: user_id = self.user_id 397 | if not email: email = self.email 398 | if not user_id or not email: 399 | raise SxTArgumentError('Must have valid UserID and Email to register a new user.', logger=self.logger) 400 | 401 | success, response = self.base_api.auth_code_register(user_id = self.user_id, email = email, joincode = join_code) 402 | if success: 403 | challenge_token = response['authCode'] 404 | signed_challenge_token = self.key_manager.sign_message(challenge_token) 405 | success, response = self.base_api.get_access_token(user_id = self.user_id, 406 | challange_token = challenge_token, 407 | signed_challange_token = signed_challenge_token, 408 | public_key = self.public_key) 409 | if success: 410 | tokens = response 411 | self.email = email 412 | self.user_id = user_id 413 | else: 414 | raise SxTAuthenticationError(str(response), logger=self.logger) 415 | if len( [v for v in tokens if v in ['accessToken','refreshToken','accessTokenExpires','refreshTokenExpires']] ) < 4: 416 | raise SxTAuthenticationError('Authentication produced incorrect / incomplete output', logger=self.logger) 417 | except SxTAuthenticationError as ex: 418 | return False, [ex] 419 | 420 | self.__settokens__(tokens['accessToken'], tokens['refreshToken'], tokens['accessTokenExpires'], tokens['refreshTokenExpires']) 421 | return True, self.access_token 422 | 423 | 424 | 425 | def authenticate(self) -> tuple[bool, object]: 426 | """-------------------- 427 | Authenticate to the Space and Time network, and store access_token and refresh_token. 428 | 429 | Returns: 430 | bool: Success flag (True/False) indicating the call worked as expected. 431 | object: Access_Token if successful, otherwise an error object. 432 | """ 433 | if not ((self.user_id and self.private_key) or self.api_key): 434 | raise SxTArgumentError('Must have valid user_id and either api_key or private_key to authenticate.', logger=self.logger) 435 | success = False 436 | 437 | try: 438 | if self.private_key and self.user_id: 439 | 440 | success, response = self.base_api.get_auth_challenge_token(user_id = self.user_id) 441 | if success: 442 | challenge_token = response['authCode'] 443 | signed_challenge_token = self.key_manager.sign_message(challenge_token) 444 | success, response = self.base_api.get_access_token(user_id = self.user_id, 445 | challange_token = challenge_token, 446 | signed_challange_token = signed_challenge_token, 447 | public_key = self.public_key) 448 | 449 | if not success and self.api_key: 450 | success, response = self.base_api.gateway_proxy_auth_apikey(self.api_key) 451 | 452 | # either way, continue on processing tokens 453 | if not success: raise SxTAuthenticationError(str(response), logger=self.logger) 454 | 455 | tokens = response 456 | if len( [v for v in tokens if v in ['accessToken','refreshToken','accessTokenExpires','refreshTokenExpires']] ) < 4: 457 | raise SxTAuthenticationError('Authentication produced incorrect / incomplete output', logger=self.logger) 458 | except SxTAuthenticationError as ex: 459 | return False, [ex] 460 | 461 | self.__settokens__(tokens['accessToken'], tokens['refreshToken'], tokens['accessTokenExpires'], tokens['refreshTokenExpires']) 462 | if self.user_id =='': self.get_user_network_info() # will set user_id if missing 463 | return True, self.access_token 464 | 465 | 466 | def reauthenticate(self) -> str: 467 | """Re-authenticate an existing access_token to the Space and Time network.""" 468 | if not self.refresh_expired: 469 | raise SxTArgumentError('Refresh token has expired', logger=self.logger) 470 | try: 471 | success, tokens = self.base_api.token_refresh(self.refresh_token) 472 | if not success: 473 | raise SxTAuthenticationError(str(tokens), logger=self.logger) 474 | if len( [v for v in tokens if v in ['accessToken','refreshToken','accessTokenExpires','refreshTokenExpires']] ) < 4: 475 | raise SxTAuthenticationError('Authentication produced incorrect / incomplete output', logger=self.logger) 476 | except SxTAuthenticationError as ex: 477 | return False, [ex] 478 | self.access_token = tokens['accessToken'] 479 | self.refresh_token = tokens['refreshToken'] 480 | self.access_token_expire_epoch = tokens['accessTokenExpires'] 481 | self.refresh_token_expire_epoch = tokens['refreshTokenExpires'] 482 | self.base_api.access_token = self.access_token 483 | return True, self.access_token 484 | 485 | def execute_sql(self, sql_text:str, biscuits:list = None, app_name:str = None): 486 | """ 487 | **Deprecated** This is a duplicate of the "execute_query" method, provided for backwards compatibility. 488 | Use the more consistent "execute_query" to avoid future deprecation issues. 489 | """ 490 | self.logger.warning('execute_sql is deprecated. Use execute_query() instead.') 491 | return self.execute_query(sql_text=sql_text, biscuits=biscuits, app_name=app_name) 492 | 493 | 494 | def execute_query(self, sql_text:str, biscuits:list = None, app_name:str = None): 495 | """ 496 | Execute a SQL query, returning success flag and data. Can be DQL, DML, or DDL. 497 | 498 | Args: 499 | sql_text (str): SQL text to execute. 500 | biscuits (list): List of biscuits required to authorize this request. 501 | app_name (str): Name of the application making the request. 502 | 503 | Returns: 504 | bool: Success flag (True/False) indicating the call worked as expected. 505 | list: Data (as list of dicts) if successful, otherwise an error object. 506 | """ 507 | return self.base_api.sql_exec(sql_text=sql_text, biscuits=biscuits, app_name=app_name) 508 | 509 | 510 | def execute_zkproven_query(self, sql_text:str, biscuits:list = None): 511 | """ 512 | Execute a zkProven SQL query, returning success flag, data, and zk metadata. 513 | 514 | Args: 515 | sql_text (str): SQL text to execute. 516 | biscuits (list): List of biscuits required to authorize this request. 517 | 518 | Returns: 519 | bool: Success flag (True/False) indicating the call worked as expected. 520 | list: Data (as list of dicts) if successful, otherwise an error object. 521 | object: Metadata reciept from the ZK Prover and Verifier. 522 | """ 523 | rtn = self.base_api.sql_exec_tamperproof(sql_text=sql_text, biscuits=biscuits) 524 | if not rtn[0]: return rtn[0], rtn[1], {} 525 | data = rtn[1].pop('data') if 'data' in rtn[1] else [] 526 | metadata = { "requestId": rtn[1]['requestId'] if 'requestId' in rtn[1] else '' 527 | ,"requestTimestamp":rtn[1]['requestTimestamp'] if 'requestTimestamp' in rtn[1] else '' 528 | ,"verificationHash":rtn[1]['metadata']['verificationHash'] if 'metadata' in rtn[1] and 'verificationHash' in rtn[1]['metadata'] else ''} 529 | return rtn[0], data, metadata 530 | 531 | 532 | def generate_joincode(self, role:str = 'member'): 533 | """ 534 | Generate an invite /joincode to join the inviting user's subscription. 535 | 536 | Args: 537 | role (str): Role level to assign the new user. Can be member, admin, or owner. 538 | 539 | Returns: 540 | str: Joincode 541 | """ 542 | success, results = self.base_api.subscription_invite_user(role) 543 | if not success: 544 | self.logger.error(str(results)) 545 | return str(results) 546 | self.logger.info('Generated joincode') 547 | return results['text'] 548 | 549 | 550 | def join_subscription(self, joincode:str): 551 | """ 552 | Join an existing subscription to the Space and Time network, based on supplied JoinCode (expires after 24 hours). 553 | Note, joining a subscription will refresh both the access_token and refresh_token. 554 | """ 555 | success, tokens = self.base_api.subscription_join(joincode=joincode) 556 | if success: 557 | self.__settokens__(tokens['accessToken'], tokens['refreshToken'], tokens['accessTokenExpires'], tokens['refreshTokenExpires']) 558 | return True, 'Consumed join_code and joined subscription!' 559 | if not success: 560 | self.logger.error(str(tokens)) 561 | return False, str(tokens) 562 | 563 | 564 | def leave_subscription(self) -> tuple[bool, dict]: 565 | """ 566 | Currently authenticated user leaves subscription. Fails if the user is not authenticated. 567 | """ 568 | if self.access_expired: return False, {"error":"disconnected - authenticate to leave subscription"} 569 | return self.base_api.subscription_leave() 570 | 571 | 572 | def remove_from_subscription(self, user_id_to_remove:str) -> tuple[bool, dict]: 573 | """ 574 | Removes another user from the current user's subscription. Current user must have more authority than the targeted user to remove. 575 | 576 | Args: 577 | User_ID_to_Remove (str): ID of the user to remove from the current user's subscription. 578 | 579 | Returns: 580 | bool: Success flag (True/False) indicating the api call worked as expected. 581 | object: Response information from the Space and Time network, as list or dict(json). 582 | """ 583 | success, response = self.base_api.subscription_remove(user_id_to_remove) 584 | if success: 585 | msg =f"Removed {user_id_to_remove} from subscription." 586 | self.logger.info(msg) 587 | return True, {"text": msg} 588 | else: 589 | self.logger.error(str(response)) 590 | return False, response 591 | 592 | 593 | def get_subscription_users(self) -> tuple[bool, dict]: 594 | """ 595 | Returns a list of all users in the current subscription. 596 | 597 | Args: 598 | None 599 | 600 | Returns: 601 | bool: Success flag (True/False) indicating the api call worked as expected. 602 | object: Dictionary of User_IDs and User Permission level in the subscription, or error as json. 603 | """ 604 | success, response = self.base_api.subscription_get_users() 605 | if success: 606 | return True, response 607 | else: 608 | self.logger.error(response) 609 | return False, response 610 | 611 | 612 | def gateway_proxy_login(self, user_id:str = None, password:str = None) -> tuple[bool, dict]: 613 | """ 614 | Login to the gateway proxy as a user, and return authentication tokens, like session_id and access_token. 615 | 616 | Args: 617 | user_id (str): User ID to login as. 618 | password (str): Password of the user to login as. 619 | 620 | Returns: 621 | bool: Success flag (True/False) indicating the api call worked as expected. 622 | object: Access token from the gateway proxy. 623 | """ 624 | if not user_id: user_id = self.user_id 625 | if not self.user_id: self.user_id = user_id 626 | if not password: password = self.gateway_password 627 | if not self.gateway_password: self.gateway_password = password 628 | return self.base_api.gateway_proxy_login(user_id, password) 629 | 630 | 631 | def gateway_proxy_change_password(self, old_password:str, new_password:str) -> tuple[bool, dict]: 632 | """-------------------- 633 | Logs into the gateway proxy and changes the user's password. Old password is required to login / authenticate change. 634 | 635 | Args: 636 | old_password (str): Current, working password 637 | new_password (str): New password 638 | 639 | Returns: 640 | bool: Success flag (True/False) indicating the api call worked as expected. 641 | object: Response information from the Gateway Proxy, as list or dict(json). 642 | """ 643 | success, response = self.base_api.gateway_proxy_change_password(self.user_id, old_password, new_password) 644 | if success and 'accessToken' in response and 'refreshToken' in response and 'accessTokenExpires' in response and 'refreshTokenExpires' in response : 645 | self.__settokens__(response['accessToken'], response['refreshToken'], response['accessTokenExpires'], response['refreshTokenExpires']) 646 | return success, response 647 | 648 | def gateway_proxy_join(self, access_token:str = None) -> tuple[bool, dict]: 649 | """-------------------- 650 | ONLY NEEDS TO BE COMPLETED ONCE: Adds an authenticated user to the gateway proxy. Fails if the user is not authenticated. 651 | 652 | Args: 653 | access_token (str): Authenticated access token for the user. 654 | 655 | Returns: 656 | bool: Success flag (True/False) indicating the api call worked as expected. 657 | dict: New Studio Password, or error message in a dict(json). 658 | """ 659 | if not access_token: access_token = self.access_token 660 | if self.access_expired: 661 | return False, {"error":"disconnected - authenticate to join gateway proxy"} 662 | return self.base_api.gateway_proxy_add_existing_user(access_token) 663 | -------------------------------------------------------------------------------- /src/spaceandtime/sxtbaseapi.py: -------------------------------------------------------------------------------- 1 | import requests, logging, json, sys 2 | from pathlib import Path 3 | 4 | # done fighting with this, sorry 5 | sxtpypath = str(Path(__file__).parent.resolve()) 6 | if sxtpypath not in sys.path: sys.path.append(sxtpypath) 7 | from sxtenums import SXTApiCallTypes 8 | from sxtexceptions import SxTArgumentError, SxTAPINotDefinedError 9 | from sxtbiscuits import SXTBiscuit 10 | 11 | 12 | class SXTBaseAPI(): 13 | __au__:str = None 14 | access_token = '' 15 | refresh_token = '' 16 | access_token_expires = 0 17 | refresh_token_expires = 0 18 | logger: logging.Logger 19 | network_calls_enabled:bool = True 20 | standard_headers = { 21 | "accept": "application/json", 22 | "content-type": "application/json" 23 | } 24 | versions = {} 25 | APICALLTYPE = SXTApiCallTypes 26 | 27 | 28 | def __init__(self, access_token:str = '', logger:logging.Logger = None) -> None: 29 | if logger: 30 | self.logger = logger 31 | else: 32 | self.logger = logging.getLogger() 33 | self.logger.setLevel(logging.INFO) 34 | if len(self.logger.handlers) == 0: 35 | self.logger.addHandler( logging.StreamHandler() ) 36 | 37 | apiversionfile = Path(Path(__file__).resolve().parent / 'apiversions.json') 38 | self.access_token = access_token 39 | with open(apiversionfile,'r') as fh: 40 | content = fh.read() 41 | self.versions = json.loads(content) 42 | 43 | def __settokens__(self, accessToken:str, accessTokenExpires:int, refreshToken:str='', refreshTokenExpires:int=0): 44 | self.access_token = accessToken 45 | self.refresh_token = refreshToken 46 | self.access_token_expires = accessTokenExpires 47 | self.refresh_token_expires = refreshTokenExpires 48 | 49 | @property 50 | def api_url(self): 51 | return self.__au__ if self.__au__ else 'https://api.makeinfinite.dev' # default 52 | @api_url.setter 53 | def api_url(self, value): 54 | self.__au__ = value 55 | 56 | def prep_biscuits(self, biscuits=[]) -> list: 57 | """-------------------- 58 | Accepts biscuits in various data types, and returns a list of biscuit_tokens as strings (list of str). 59 | Primary use-case is class-internal. 60 | 61 | Args: 62 | biscuits (list | str | SXTBiscuit): biscuit_tokens as a list, str, or SXTBiscuit type. 63 | 64 | Returns: 65 | list: biscuit_tokens as a list. 66 | 67 | Examples: 68 | >>> sxt = SpaceAndTime() 69 | >>> biscuits = sxt.user.base_api.prep_biscuits(['a',['b','c'], 'd']) 70 | >>> biscuits == ['a', 'b', 'c', 'd'] 71 | True 72 | """ 73 | if biscuits == None or len(biscuits) == 0: 74 | return [] 75 | elif type(biscuits) == str: 76 | return [biscuits] 77 | elif 'SXTBiscuit' in str(type(biscuits)): 78 | return [biscuits.biscuit_token] 79 | elif type(biscuits) == list: 80 | rtn=[] 81 | for biscuit in biscuits: 82 | rtn = rtn + self.prep_biscuits(biscuit) 83 | return rtn 84 | else: 85 | self.logger.warning(f"""Biscuit provided was an unexpected type: {type(biscuits)} 86 | Type must be one of [ str | list | SXTBiscuit object | None ] 87 | Ingnoring this biscuit entry. Biscuit value provided: 88 | {biscuits}""") 89 | return [] 90 | 91 | 92 | def prep_sql(self, sql_text:str) -> str: 93 | """------------------- 94 | Cleans and prepares sql_text for transmission and execution on-network. 95 | 96 | Args: 97 | sql_text (str): SQL text to prepare. 98 | 99 | Returns: 100 | sql: slightly modified / cleansed SQL text 101 | 102 | Examples: 103 | >>> api = SXTBaseAPI() 104 | >>> sql = "Select 'complex \nstring ' as A \n \t from \n\t TableName \n Where A=1;" 105 | >>> newsql = api.prep_sql(sql) 106 | >>> newsql == "Select 'complex \nstring ' as A from TableName Where A=1" 107 | True 108 | """ 109 | if sql_text == None or len(sql_text.strip()) == 0: return '' 110 | insinglequote = False 111 | indoublequote = False 112 | rtn = [] 113 | char = prevchar = '' 114 | for char in list(sql_text.strip()): 115 | 116 | # escape anything in quotes 117 | if char == "'": insinglequote = not insinglequote 118 | elif char == '"': indoublequote = not indoublequote 119 | if insinglequote or indoublequote: 120 | rtn.append(char) 121 | prevchar = '' 122 | continue 123 | 124 | # replace newlines and tabs with spaces 125 | if char in ['\n', '\t']: char = ' ' 126 | 127 | # remove double-spaces 128 | if char == ' ' and prevchar == ' ': continue 129 | 130 | rtn.append(char) 131 | prevchar = char 132 | 133 | # remove ; if last character 134 | if char == ';': rtn = rtn[:-1] 135 | return str(''.join(rtn)).strip() 136 | 137 | 138 | def call_api(self, endpoint: str, 139 | auth_header:bool = True, 140 | request_type:str = SXTApiCallTypes.POST, 141 | header_parms: dict = {}, 142 | data_parms: dict = {}, 143 | query_parms: dict = {}, 144 | path_parms: dict = {}, 145 | endpoint_full_override_flag: bool = False ) -> tuple[bool, object]: 146 | """-------------------- 147 | Generic function to call and return SxT API. 148 | 149 | This is the base api execution function. It can, but is not intended, to be used directly. 150 | Rather, it is wrapped by other api-specific functions, to isolate api call differences 151 | from the actual api execution, which can all be the same. 152 | 153 | Args: 154 | endpoint (str): URL endpoint, after the version. Final structure is: [api_url/version/endpoint] 155 | request_type (SXTApiCallTypes): Type of request. [POST, GET, PUT, DELETE] 156 | auth_header (bool): flag indicator whether to append the Bearer token to the header. 157 | header_parms: (dict): Name/Value pair to add to request header, except for bearer token. {Name: Value} 158 | query_parms: (dict): Name/value pairs to be added to the query string. {Name: Value} 159 | data_parms (dict): Dictionary to be used holistically for --data json object. 160 | path_parms (dict): Pattern to replace placeholders in URL. {Placeholder_in_URL: Replace_Value} 161 | endpoint_full_override_flag (str): If True, endpoint is used verbatium, rather than constructing version + endpoint. Will negate any querystring or path parms. 162 | 163 | Results: 164 | bool: Indicating request success 165 | json: Result of the API, expressed as a JSON object 166 | """ 167 | # Set these early, in case of timeout and they're not set by callfunc 168 | txt = 'response.text not available - are you sure you have the correct API Endpoint?' 169 | statuscode = 555 170 | response = {} 171 | 172 | # if network calls turned off, return fake data 173 | if not self.network_calls_enabled: return True, self.__fakedata__(endpoint) 174 | 175 | # internal function to simplify and unify error handling 176 | def __handle_errors__(txt, ex, statuscode, responseobject, loggerobject): 177 | loggerobject.error(txt) 178 | rtn = {'text':txt} 179 | rtn['error'] = str(ex) 180 | rtn['status_code'] = statuscode 181 | rtn['response_object'] = responseobject 182 | return False, rtn 183 | 184 | # otherwise, go get real data 185 | try: 186 | # Header parms 187 | headers = {k:v for k,v in self.standard_headers.items()} # get new object 188 | if auth_header: headers['authorization'] = f'Bearer {self.access_token}' 189 | headers.update(header_parms) 190 | 191 | 192 | if endpoint_full_override_flag: 193 | url = endpoint 194 | self.logger.debug(f'API Call started for (custom) endpoint: {endpoint}') 195 | 196 | else: 197 | if endpoint not in self.versions.keys() and not endpoint_full_override_flag: 198 | raise SxTAPINotDefinedError("Endpoint not defined in API Lookup (apiversions.json). Please reach out to Space and Time for assistance. \nAs a work-around, you can try manually adding the endpoint to the SXTBaseAPI.versions dictionary.") 199 | version = self.versions[endpoint] 200 | self.logger.debug(f'API Call started for endpoint: {version}/{endpoint}') 201 | 202 | # Path parms 203 | for name, value in path_parms.items(): 204 | endpoint = endpoint.replace(f'{{{name}}}', value) 205 | 206 | # Query parms 207 | if query_parms !={}: 208 | endpoint = f'{endpoint}?' + '&'.join([f'{n}={v}' for n,v in query_parms.items()]) 209 | 210 | # final URL 211 | url = f'{self.api_url}/{version}/{endpoint}' 212 | 213 | if request_type not in SXTApiCallTypes: 214 | msg = f'request_type must be of type SXTApiCallTypes, not { type(request_type) }' 215 | raise SxTArgumentError(msg, logger=self.logger) 216 | 217 | # Call API function as defined above 218 | from pprint import pprint 219 | self.logger.debug(f'\nNew API call for endpoint: {url}') 220 | self.logger.debug(' headers:') 221 | self.logger.debug( headers if self.access_token=='' else str(headers).replace(self.access_token,'<>') ) 222 | self.logger.debug(' data parms:') 223 | self.logger.debug( data_parms if self.access_token=='' else str(data_parms).replace(self.access_token,'<>') ) 224 | match request_type: 225 | case SXTApiCallTypes.POST : response = requests.post(url=url, data=json.dumps(data_parms), headers=headers) 226 | case SXTApiCallTypes.GET : response = requests.get(url=url, data=json.dumps(data_parms), headers=headers) 227 | case SXTApiCallTypes.PUT : response = requests.put(url=url, data=json.dumps(data_parms), headers=headers) 228 | case SXTApiCallTypes.DELETE : response = requests.delete(url=url, data=json.dumps(data_parms), headers=headers) 229 | 230 | txt = response.text 231 | statuscode = response.status_code 232 | response.raise_for_status() 233 | 234 | try: 235 | self.logger.debug('API return content type: ' + response.headers.get('content-type','') ) 236 | rtn = response.json() 237 | except json.decoder.JSONDecodeError as ex: 238 | rtn = {'text':txt, 'status_code':statuscode} 239 | 240 | self.logger.debug( f'API call completed for endpoint: "{endpoint}" with result: {txt[:2000]}') 241 | return True, rtn 242 | 243 | except requests.exceptions.RequestException as ex: 244 | return __handle_errors__(txt, ex, statuscode, response, self.logger) 245 | except SxTAPINotDefinedError as ex: 246 | return __handle_errors__(txt, ex, statuscode, response, self.logger) 247 | except Exception as ex: 248 | return __handle_errors__(txt, ex, statuscode, response, self.logger) 249 | 250 | 251 | def __fakedata__(self, endpoint:str): 252 | if endpoint in ['sql','sql/dql']: 253 | rtn = [{'id':'1', 'str':'a','this_record':'is a test'}] 254 | rtn.append( {'id':'2', 'str':'b','this_record':'is a test'} ) 255 | rtn.append( {'id':'3', 'str':'c','this_record':'is a test'} ) 256 | return rtn 257 | else: 258 | return {'authCode':'469867d9660b67f8aa12b2' 259 | ,'accessToken':'eyJ0eXBlIjoiYWNjZXNzIiwia2lkIjUxNDVkYmQtZGNmYi00ZjI4LTg3NzItZjVmNjNlMzcwM2JlIiwiYWxnIjoiRVMyNTYifQ.eyJpYXQiOjE2OTczOTM1MDIsIm5iZiI6MTY5NzM5MzUwMiwiZXhwIjoxNjk3Mzk1MDAyLCJ0eXBlIjoiYWNjZXNzIiwidXNlciI6InN0ZXBoZW4iLCJzdWJzY3JpcHRpb24iOiIzMWNiMGI0Yi0xMjZlLTRlM2MtYTdhMS1lNWRmNDc4YTBjMDUiLCJzZXNzaW9uIjoiMzNiNGRhMzYxZjZiNTM3MjZlYmYyNzU4Iiwic3NuX2V4cCI6MTY5NzQ3OTkwMjMxNSwiaXRlcmF0aW9uIjoiNDEwY2YyZTgyYWZlODdmNDRiMzE4NDFiIn0.kpvrG-ro13P1YeMF6sjLh8wn1rO3jpCVeTrzhDe16ZmJu4ik1amcYz9uQff_XQcwBDrpnCeD5ZZ9mHqb_basew' 260 | ,'refreshToken':'eyJ0eXBlIjoicmVmcmVzaCIsImtpZCITQ1ZGJkLWRjZmItNGYyOC04NzcyLWY1ZjYzZTM3MDNiZSIsImFsZyI6IkVTMjU2In0.eyJpYXQiOjE2OTczOTM1MDIsIm5iZiI6MTY5NzM5MzUwMiwiZXhwIjoxNjk3Mzk1MzAyLCJ0eXBlIjoicmVmcmVzaCIsInVzZXIiOiJzdGVwaGVuIiwic3Vic2NyaXB0aW9uIjoiMzFjYjBiNGItMTI2ZS00ZTNjLWE3YTEtZTVkZjQ3OGEwYzA1Iiwic2Vzc2lvbiI6IjMzYjRkYTM2MWY2YjUzNzI2ZWJmMjc1OCIsInNzbl9leHAiOjE2OTc0Nzk5MDIzMTUsIml0ZXJhdGlvbiI6IjQxMGNmMmU4MmFmZTg3ZjQ0YjMxODQxYiJ9.3vVYpTGBjXIejlaacaZOh_59O9ETfbvTCWvldoi0ojyXTRkTmENVpQRbw7av7yMM2jA7SRdEPQGGjYmThCfk9w' 261 | ,'accessTokenExpires':1973950023160 262 | ,'refreshTokenExpires':1973953023160 263 | } 264 | 265 | 266 | def get_auth_challenge_token(self, user_id:str, prefix:str = None, joincode:str = None): 267 | """-------------------- 268 | (alias) Calls and returns data from API: auth/code, which issues a random challenge token to be signed as part of the authentication workflow. 269 | 270 | Args: 271 | user_id (str): UserID to be authenticated 272 | prefix (str): (optional) The message prefix for signature verification (used for improved front-end UX). 273 | joincode (str): (optional) Joincode if creating a new user within an existing subscription. 274 | 275 | Returns: 276 | bool: Success flag (True/False) indicating the api call worked as expected. 277 | object: Response information from the Space and Time network, as list or dict(json). 278 | """ 279 | return self.auth_code(user_id, prefix, joincode) 280 | 281 | 282 | def auth_code(self, user_id:str, prefix:str = None, joincode:str = None) -> tuple[bool, object]: 283 | """-------------------- 284 | Calls and returns data from API: auth/code, which issues a random challenge token to be signed as part of the authentication workflow. 285 | 286 | Args: 287 | user_id (str): UserID to be authenticated 288 | prefix (str): (optional) The message prefix for signature verification (used for improved front-end UX). 289 | joincode (str): (optional) Joincode if creating a new user within an existing subscription. 290 | 291 | Returns: 292 | bool: Success flag (True/False) indicating the api call worked as expected. 293 | object: Response information from the Space and Time network, as list or dict(json). 294 | """ 295 | dataparms = {"userId": user_id} 296 | if prefix: dataparms["prefix"] = prefix 297 | if joincode: 298 | success, rtn = self.auth_code_register(user_id, prefix, joincode) 299 | else: 300 | success, rtn = self.call_api(endpoint = 'auth/code', auth_header = False, data_parms = dataparms) 301 | return success, rtn if success else [rtn] 302 | 303 | 304 | def gateway_proxy_add_existing_user(self, access_token:str) -> tuple[bool, object]: 305 | """-------------------- 306 | Adds an authenticated user to the gateway proxy. Fails if the user is not authenticated. 307 | 308 | Args: 309 | user_id (str): UserID to be authenticated 310 | 311 | Returns: 312 | bool: Success flag (True/False) indicating the api call worked as expected. 313 | dict: New Studio Password, or error message in a dict(json). 314 | """ 315 | endpoint = f'https://proxy.api.makeinfinite.dev/auth/add-existing?accessToken={access_token}' 316 | success, response = self.call_api(endpoint = endpoint, 317 | auth_header = False, 318 | endpoint_full_override_flag=True) 319 | self.logger.warning(f'add_user_to_gateway_proxy: {response}') 320 | if success and 'tempPassword' in response: response = response['tempPassword'] 321 | return success, response 322 | 323 | 324 | 325 | def gateway_proxy_login (self, user_id:str, password:str) -> tuple[bool, object]: 326 | """-------------------- 327 | Login to the gateway proxy, and return the session id. 328 | 329 | Args: 330 | user_id (str): UserID to be authenticated 331 | password (str): Current, working password 332 | 333 | Returns: 334 | bool: Success flag (True/False) indicating the api call worked as expected. 335 | object: Response information from the Gateway Proxy, including the session id, access token, etc. 336 | """ 337 | endpoint = 'https://proxy.api.makeinfinite.dev/auth/login' 338 | success, response = self.call_api(endpoint = endpoint, 339 | auth_header = False, 340 | data_parms = {"userId": user_id, "password": password}, 341 | endpoint_full_override_flag=True) 342 | return success, response 343 | 344 | 345 | 346 | def gateway_proxy_change_password(self, user_id:str, old_password:str, new_password:str, session_id:str = None) -> tuple[bool, object]: 347 | """-------------------- 348 | Logs into the gateway proxy and changes the user's password. Assuming the old_password still works, does not require network authentication. 349 | 350 | Args: 351 | user_id (str): UserID to be authenticated 352 | old_password (str): Current, working password 353 | new_password (str): New password 354 | session_id (str): (optional) Session ID if already authenticated, otherwise this function will login and return authentication information as well. 355 | 356 | Returns: 357 | bool: Success flag (True/False) indicating the api call worked as expected. 358 | object: Response information from the Gateway Proxy, as list or dict(json). 359 | """ 360 | endpoint = 'https://proxy.api.makeinfinite.dev/auth/reset' 361 | if not session_id: 362 | success, login_response = self.gateway_proxy_login(user_id, old_password) 363 | if not success: 364 | raise SxTArgumentError(f'Failed to log into gateway proxy: {login_response}') 365 | session_id = login_response['sessionId'] 366 | 367 | success, response = self.call_api(endpoint = endpoint, 368 | auth_header = False, 369 | header_parms={"sid": session_id}, 370 | data_parms = {"userId": user_id, "sid": session_id, 371 | "tempPassword": old_password, "newPassword": new_password}, 372 | endpoint_full_override_flag=True) 373 | self.logger.warning(f'changed gateway proxy password: {response}') 374 | if login_response: response.update(login_response) 375 | response['password'] = new_password 376 | return success, response 377 | 378 | 379 | def gateway_proxy_auth_apikey(self, api_key:str) -> tuple[bool, object]: 380 | """-------------------- 381 | Logs into the gateway proxy using an API Key and returns an access token. 382 | 383 | Args: 384 | api_key (str): API Key 385 | 386 | Returns: 387 | bool: Success flag (True/False) indicating the api call worked as expected. 388 | object: Response information from the Gateway Proxy, as list or dict(json). 389 | """ 390 | endpoint = 'https://proxy.api.makeinfinite.dev/auth/apikey' 391 | if not api_key: raise SxTArgumentError('api_key is required') 392 | success, response = self.call_api(endpoint = endpoint, 393 | auth_header = False, 394 | header_parms = {"apikey": api_key}, 395 | endpoint_full_override_flag=True) 396 | if success: 397 | response['refreshToken'] = response['refreshTokenExpires'] = '' 398 | self.__settokens__(response['accessToken'], response['accessTokenExpires']) 399 | return success, response 400 | 401 | 402 | def auth_apikey(self, api_key:str) -> tuple[bool, object]: 403 | """-------------------- 404 | Logs into the gateway proxy using an API Key and returns an access token. This is an alias for gateway_proxy_auth_apikey(). 405 | 406 | Args: 407 | api_key (str): API Key 408 | 409 | Returns: 410 | bool: Success flag (True/False) indicating the api call worked as expected. 411 | object: Response information from the Gateway Proxy, as list or dict(json). 412 | """ 413 | return self.gateway_proxy_auth_apikey(api_key) 414 | 415 | 416 | 417 | def auth_code_register(self, user_id:str, email:str, joincode:str = None, prefix:str = None) -> tuple[bool, object]: 418 | """-------------------- 419 | Calls and returns data from API: auth/code-register, which issues a random challenge token to be signed as part of the authentication workflow, but also requires additional information with which to register a new user on the network. 420 | 421 | Args: 422 | user_id (str): UserID to be authenticated 423 | email (str): Email address to validate new user 424 | joincode (str): (optional) Joincode if creating a new user within an existing subscription. 425 | prefix (str): (optional) The message prefix for signature verification (used for improved front-end UX). 426 | 427 | Returns: 428 | bool: Success flag (True/False) indicating the api call worked as expected. 429 | object: Response information from the Space and Time network, as list or dict(json). 430 | """ 431 | dataparms = {"userId": user_id, "email": email} 432 | if prefix: dataparms["joincode"] = joincode 433 | if prefix: dataparms["prefix"] = prefix 434 | 435 | success, rtn = self.call_api(endpoint = 'auth/code-register', auth_header = False, data_parms = dataparms) 436 | return success, rtn if success else [rtn] 437 | 438 | 439 | def get_access_token(self, user_id:str, challange_token:str, signed_challange_token:str='', public_key:str=None, keymanager:object=None, scheme:str = "ed25519"): 440 | """-------------------- 441 | (alias) Calls and returns data from API: auth/token, which validates signed challenge token and provides new Access_Token and Refresh_Token. 442 | Can optionally supply a keymanager object, instead of the public_key and signed_challenge_token. 443 | 444 | Returns: 445 | bool: Success flag (True/False) indicating the api call worked as expected. 446 | object: Response information from the Space and Time network, as list or dict(json). 447 | """ 448 | return self.auth_token(user_id, challange_token, signed_challange_token, public_key, keymanager, scheme) 449 | 450 | 451 | def auth_token(self, user_id:str, challange_token:str, signed_challange_token:str='', public_key:str=None, keymanager:object=None, scheme:str = "ed25519"): 452 | """-------------------- 453 | Calls and returns data from API: auth/token, which validates signed challenge token and provides new Access_Token and Refresh_Token. 454 | Can optionally supply a keymanager object, instead of the public_key and signed_challenge_token. 455 | 456 | Returns: 457 | bool: Success flag (True/False) indicating the api call worked as expected. 458 | object: Response information from the Space and Time network, as list or dict(json). 459 | """ 460 | if keymanager: 461 | try: 462 | public_key = keymanager.public_key_to(keymanager.ENCODINGS.BASE64) 463 | signed_challange_token = keymanager.sign_message(challange_token) 464 | except Exception as ex: 465 | return False, {'error':'keymanager object must be of type SXTKeyManager, if supplied.'} 466 | 467 | dataparms = { "userId": user_id 468 | ,"signature": signed_challange_token 469 | ,"authCode": challange_token 470 | ,"key": public_key 471 | ,"scheme": scheme} 472 | success, rtn = self.call_api(endpoint='auth/token', auth_header=False, data_parms=dataparms) 473 | if success: 474 | self.__settokens__(rtn['accessToken'], rtn['accessTokenExpires'], rtn['refreshToken'], rtn['refreshTokenExpires']) 475 | return success, rtn if success else [rtn] 476 | 477 | 478 | def token_refresh(self, refresh_token:str): 479 | """-------------------- 480 | Calls and returns data from API: auth/refresh, which accepts a Refresh_Token and provides a new Access_Token and Refresh_Token. 481 | 482 | Returns: 483 | bool: Success flag (True/False) indicating the api call worked as expected. 484 | object: Response information from the Space and Time network, as list or dict(json). 485 | """ 486 | headers = { 'authorization': f'Bearer {refresh_token}' } 487 | success, rtn = self.call_api('auth/refresh', False, header_parms=headers) 488 | if success: 489 | self.__settokens__(rtn['accessToken'], rtn['accessTokenExpires'], rtn['refreshToken'], rtn['refreshTokenExpires']) 490 | return success, rtn if success else [rtn] 491 | 492 | 493 | def auth_logout(self): 494 | """-------------------- 495 | Calls and returns data from API: auth/logout, which invalidates Access_Token and Refresh_Token. 496 | 497 | Returns: 498 | bool: Success flag (True/False) indicating the api call worked as expected. 499 | object: Response information from the Space and Time network, as list or dict(json). 500 | """ 501 | success, rtn = self.call_api('auth/logout', True) 502 | return success, rtn if success else [rtn] 503 | 504 | 505 | def auth_validtoken(self): 506 | """-------------------- 507 | Calls and returns data from API: auth/validtoken, which returns information on a valid token. 508 | 509 | Returns: 510 | bool: Success flag (True/False) indicating the api call worked as expected. 511 | object: Response information from the Space and Time network, as list or dict(json). 512 | """ 513 | success, rtn = self.call_api('auth/validtoken', True, SXTApiCallTypes.GET) 514 | return success, rtn if success else [rtn] 515 | 516 | 517 | def auth_idexists(self, user_id:str ): 518 | """-------------------- 519 | Calls and returns data from API: auth/idexists, which returns True if the User_ID supplied exists, False if not. 520 | 521 | Returns: 522 | bool: Success flag (True/False) indicating the api call worked as expected. 523 | object: Response information from the Space and Time network, as list or dict(json). 524 | """ 525 | success, rtn = self.call_api('auth/idexists/{id}', False, SXTApiCallTypes.GET, path_parms={'id':user_id}) 526 | return success, rtn if success else [rtn] 527 | 528 | 529 | def auth_keys(self): 530 | """-------------------- 531 | Calls and returns data from API: auth/keys (get), which returns all keys for a valid token. 532 | 533 | Returns: 534 | bool: Success flag (True/False) indicating the api call worked as expected. 535 | object: Response information from the Space and Time network, as list or dict(json). 536 | """ 537 | success, rtn = self.call_api('auth/keys', True, SXTApiCallTypes.GET) 538 | return success, rtn if success else [rtn] 539 | 540 | 541 | def auth_addkey(self, user_id:str, public_key:str, challange_token:str, signed_challange_token:str, scheme:str = "ed25519"): 542 | """-------------------- 543 | Calls and returns data from API: auth/keys (post), which adds a new key to the valid token. Requires similar challenge/sign/return as authentication. 544 | 545 | Returns: 546 | bool: Success flag (True/False) indicating the api call worked as expected. 547 | object: Response information from the Space and Time network, as list or dict(json). 548 | """ 549 | dataparms = { "authCode": challange_token 550 | ,"signature": signed_challange_token 551 | ,"key": public_key 552 | ,"scheme": scheme } 553 | success, rtn = self.call_api('auth/keys', True, SXTApiCallTypes.POST, data_parms=dataparms) 554 | return success, rtn if success else [rtn] 555 | 556 | 557 | def auth_addkey_challenge(self): 558 | """-------------------- 559 | Request a challenge token from the Space and Time network, for authentication. 560 | 561 | (alias) Calls and returns data from API: auth/keys (get), which returns all keys for a valid token. 562 | 563 | Returns: 564 | bool: Success flag (True/False) indicating the api call worked as expected. 565 | object: Response information from the Space and Time network, as list or dict(json). 566 | """ 567 | return self.auth_keys_code() 568 | 569 | 570 | def auth_keys_code(self): 571 | """-------------------- 572 | Calls and returns data from API: auth/keys (get), which returns all keys for a valid token. 573 | 574 | Returns: 575 | bool: Success flag (True/False) indicating the api call worked as expected. 576 | object: Response information from the Space and Time network, as list or dict(json). 577 | """ 578 | success, rtn = self.call_api('auth/keys/code', True) 579 | return success, rtn if success else [rtn] 580 | 581 | 582 | def sql_exec(self, sql_text:str, biscuits:list = None, app_name:str = None, validate:bool = False): 583 | """-------------------- 584 | Executes a database statement/query of arbitrary type (DML, DDL, DQL), and returns a status or data. 585 | 586 | Calls and returns data from API: sql, which runs arbitrary SQL and returns records (if any). 587 | This api call undergoes one additional SQL parse step to interrogate the type and 588 | affected tables / views, so is slightly less performant (by 50-100ms) than the type-specific 589 | api calls, sql_ddl, sql_dml, sql_dql. Normal human interaction will not be noticed, but 590 | if tuning for high-performance applications, consider using the correct typed call. 591 | 592 | Args: 593 | sql_text (str): SQL query text to execute. Note, there is NO placeholder replacement. 594 | biscuits (list): (optional) List of biscuit tokens for permissioned tables. If only querying public tables, this is not needed. 595 | app_name (str): (optional) Name that will appear in querylog, used for bucketing workload. 596 | validate (bool): (optional) Perform an additional SQL validation in-parser, before database submission. 597 | 598 | Returns: 599 | bool: Success flag (True/False) indicating the api call worked as expected. 600 | object: Response information from the Space and Time network, as list or dict(json). 601 | """ 602 | headers = { 'originApp': app_name } if app_name else {} 603 | sql_text = self.prep_sql(sql_text=sql_text) 604 | biscuit_tokens = self.prep_biscuits(biscuits) 605 | if type(biscuit_tokens) != list: raise SxTArgumentError("sql_all requires parameter 'biscuits' to be a list of biscuit_tokens or SXTBiscuit objects.", logger = self.logger) 606 | dataparms = {"sqlText": sql_text 607 | ,"biscuits": biscuit_tokens 608 | ,"validate": str(validate).lower() } 609 | success, rtn = self.call_api('sql', True, header_parms=headers, data_parms=dataparms) 610 | return success, rtn if success else [rtn] 611 | 612 | 613 | def sql_ddl(self, sql_text:str, biscuits:list = None, app_name:str = None): 614 | """-------------------- 615 | **deprecated** -- now simply calls sql_exec. 616 | 617 | Args: 618 | sql_text (str): SQL query text to execute. Note, there is NO placeholder replacement. 619 | biscuits (list): (optional) List of biscuit tokens for permissioned tables. If only querying public tables, this is not needed. 620 | app_name (str): (optional) Name that will appear in querylog, used for bucketing workload. 621 | 622 | Returns: 623 | bool: Success flag (True/False) indicating the api call worked as expected. 624 | object: Response information from the Space and Time network, as list or dict(json). 625 | """ 626 | return self.sql_exec(sql_text=sql_text, biscuits=biscuits, app_name=app_name) 627 | 628 | 629 | def sql_dml(self, sql_text:str, resources:list, biscuits:list = None, app_name:str = None): 630 | """-------------------- 631 | **deprecated** -- now simply calls sql_exec. 632 | 633 | Args: 634 | sql_text (str): SQL query text to execute. Note, there is NO placeholder replacement. 635 | resources (list): ** ignored / unneeded ** 636 | biscuits (list): (optional) List of biscuit tokens for permissioned tables. If only querying public tables, this is not needed. 637 | app_name (str): (optional) Name that will appear in querylog, used for bucketing workload. 638 | 639 | Returns: 640 | bool: Success flag (True/False) indicating the api call worked as expected. 641 | object: Response information from the Space and Time network, as list or dict(json). 642 | """ 643 | return self.sql_exec(sql_text=sql_text, biscuits=biscuits, app_name=app_name) 644 | 645 | 646 | def sql_dql(self, sql_text:str, resources:list, biscuits:list = None, app_name:str = None): 647 | """-------------------- 648 | **deprecated** -- now simply calls sql_exec. 649 | 650 | Args: 651 | sql_text (str): SQL query text to execute. Note, there is NO placeholder replacement. 652 | resources (list): ** ignored / unneeded ** 653 | biscuits (list): (optional) List of biscuit tokens for permissioned tables. If only querying public tables, this is not needed. 654 | app_name (str): (optional) Name that will appear in querylog, used for bucketing workload. 655 | 656 | Returns: 657 | bool: Success flag (True/False) indicating the api call worked as expected. 658 | object: Response information from the Space and Time network, as list or dict(json). 659 | """ 660 | return self.sql_exec(sql_text=sql_text, biscuits=biscuits, app_name=app_name) 661 | 662 | 663 | def sql_exec_tamperproof(self, sql_text:str, biscuits:list = None): 664 | """-------------------- 665 | Executes a ZK tamperproof database statement/query, and returns a status or data plus a ZK verification code. 666 | 667 | Args: 668 | sql_text (str): SQL query text to execute. Note, there is NO placeholder replacement. 669 | biscuits (list): (optional) List of biscuit tokens for permissioned tables. If only querying public tables, this is not needed. 670 | 671 | Returns: 672 | bool: Success flag (True/False) indicating the api call worked as expected. 673 | object: Response information from the Space and Time network, as list or dict(json). 674 | """ 675 | sql_text = self.prep_sql(sql_text=sql_text) 676 | biscuit_tokens = self.prep_biscuits(biscuits) 677 | if type(biscuit_tokens) != list: raise SxTArgumentError("sql_all requires parameter 'biscuits' to be a list of biscuit_tokens or SXTBiscuit objects.", logger = self.logger) 678 | dataparms = {"sqlText": sql_text 679 | ,"biscuits": biscuit_tokens } 680 | success, rtn = self.call_api('sql/tamperproof-query', True, data_parms=dataparms) 681 | return success, rtn if success else [rtn] 682 | 683 | 684 | def discovery_get_schemas(self, scope:str = 'ALL'): 685 | """-------------------- 686 | Connects to the Space and Time network and returns all available schemas. 687 | 688 | Calls and returns data from API: discover/schema 689 | 690 | Args: 691 | scope (SXTDiscoveryScope): (optional) Scope of objects to return: All, Public, Subscription, or Private. Defaults to SXTDiscoveryScope.ALL. 692 | 693 | Returns: 694 | bool: Success flag (True/False) indicating the api call worked as expected. 695 | object: Response information from the Space and Time network, as list of dict. 696 | """ 697 | allowed_scope = ['subscription', 'public', 'all'] 698 | if scope.lower() not in allowed_scope: 699 | raise SxTArgumentError(f"Invalid value for scope '{scope}'. Must be one of {allowed_scope}.", logger = self.logger) 700 | success, rtn = self.call_api('discover/schema',True, SXTApiCallTypes.GET, query_parms={'scope':scope}) 701 | return success, (rtn if success else [rtn]) 702 | 703 | 704 | def discovery_get_tables(self, schema:str = 'ETHEREUM', scope:str = 'ALL', search_pattern:str = None): 705 | """-------------------- 706 | Connects to the Space and Time network and returns all available tables within a schema. 707 | 708 | Calls and returns data from API: discover/table 709 | 710 | Args: 711 | schema (str): Schema name to search for tables. 712 | scope (SXTDiscoveryScope): (optional) Scope of objects to return: All, Public, Subscription, or Private. Defaults to SXTDiscoveryScope.ALL. 713 | search_pattern (str): (optional) Tablename pattern to match for inclusion into result set. Defaults to None / all tables. 714 | 715 | Returns: 716 | bool: Success flag (True/False) indicating the api call worked as expected. 717 | object: Response information from the Space and Time network, as list of dict. 718 | """ 719 | allowed_scope = ['subscription', 'public', 'all'] 720 | if scope.lower() not in allowed_scope: 721 | raise SxTArgumentError(f"Invalid value for scope '{scope}'. Must be one of {allowed_scope}.", logger = self.logger) 722 | scope = 'PUBLIC' if scope.upper() == 'PUBLIC' else 'SUBSCRIPTION' 723 | version = 'v2' if 'discover/table' not in list(self.versions.keys()) else self.versions['discover/table'] 724 | schema_or_namespace = 'namespace' if version=='v1' else 'schema' 725 | query_parms = {'scope':scope, schema_or_namespace:schema.upper()} 726 | if version != 'v1' and search_pattern: query_parms['searchPattern'] = search_pattern 727 | 728 | success, rtn = self.call_api('discover/table', True, SXTApiCallTypes.GET, query_parms=query_parms) 729 | return success, (rtn if success else [rtn]) 730 | 731 | 732 | def discovery_get_views(self, schema:str = 'ETHEREUM', scope:str = 'ALL', search_pattern:str = None): 733 | """-------------------- 734 | Connects to the Space and Time network and returns all available tables within a schema. 735 | 736 | Calls and returns data from API: discover/table 737 | 738 | Args: 739 | schema (str): Schema name to search for tables. 740 | scope (SXTDiscoveryScope): (optional) Scope of objects to return: All, Public, Subscription, or Private. Defaults to SXTDiscoveryScope.ALL. 741 | search_pattern (str): (optional) Tablename pattern to match for inclusion into result set. Defaults to None / all tables. 742 | 743 | Returns: 744 | bool: Success flag (True/False) indicating the api call worked as expected. 745 | object: Response information from the Space and Time network, as list of dict. 746 | """ 747 | version = 'v2' if 'discover/view' not in list(self.versions.keys()) else self.versions['discover/view'] 748 | scope = 'PUBLIC' if scope.upper() == 'PUBLIC' else 'SUBSCRIPTION' 749 | query_parms = {'scope':scope, 'schema':schema.upper()} 750 | if version != 'v1' and search_pattern: query_parms['searchPattern'] = search_pattern 751 | success, rtn = self.call_api('discover/view',True, SXTApiCallTypes.GET, query_parms=query_parms) 752 | return success, (rtn if success else [rtn]) 753 | 754 | 755 | def discovery_get_columns(self, schema:str, table:str): 756 | """-------------------- 757 | Connects to the Space and Time network and returns all available columns within a table. 758 | 759 | Calls and returns data from API: discover/table 760 | 761 | Args: 762 | schema (str): Schema name for which to retrieve tables. 763 | table (str): Table name for which to retrieve columns. This should be tablename only, NOT schema.tablename. 764 | 765 | Returns: 766 | bool: Success flag (True/False) indicating the api call worked as expected. 767 | object: Response information from the Space and Time network, as list of dict. 768 | """ 769 | version = 'v2' if 'discover/table/column' not in list(self.versions.keys()) else self.versions['discover/table/column'] 770 | schema_or_namespace = 'namespace' if version=='v1' else 'schema' 771 | query_parms = {schema_or_namespace:schema.upper(), 'table':table} 772 | success, rtn = self.call_api('discover/table/column',True, SXTApiCallTypes.GET, query_parms=query_parms) 773 | return success, (rtn if success else [rtn]) 774 | 775 | 776 | 777 | def subscription_set_name(self, name:str) -> tuple[bool, dict]: 778 | """-------------------- 779 | Assigns a user-friendly name to an existing subscription. 780 | 781 | Args: 782 | name (str): Subscription user-friendly name. 783 | 784 | Returns: 785 | bool: Success flag (True/False) indicating the api call worked as expected. 786 | object: Response information from the Space and Time network, as list or dict(json). 787 | """ 788 | if len(name)==0: return False, 'Name cannot be empty.' 789 | success, rtn = self.call_api('subscription/name', True, SXTApiCallTypes.PUT, query_parms={'subscriptionName':name}) 790 | return success, {n:v for n,v in rtn.items() if v} if success else rtn 791 | 792 | 793 | def subscription_get_info(self): 794 | """-------------------- 795 | Retrieves information on the authenticated user's subscription from the Space and Time network. 796 | 797 | Calls and returns data from API: subscription 798 | 799 | Args: 800 | None 801 | 802 | Returns: 803 | bool: Success flag (True/False) indicating the api call worked as expected. 804 | object: Response information from the Space and Time network, as list or dict(json). 805 | """ 806 | endpoint = 'subscription' 807 | version = 'v2' if endpoint not in list(self.versions.keys()) else self.versions[endpoint] 808 | success, rtn = self.call_api(endpoint=endpoint, auth_header=True, request_type=SXTApiCallTypes.GET ) 809 | return success, (rtn if success else [rtn]) 810 | 811 | 812 | def subscription_get_users(self): 813 | """-------------------- 814 | Retrieves information on all users of a subscription from the Space and Time network. May be restricted to Admin or Owners. 815 | 816 | Calls and returns data from API: subscription/users 817 | 818 | Args: 819 | None 820 | 821 | Returns: 822 | bool: Success flag (True/False) indicating the api call worked as expected. 823 | object: Response information from the Space and Time network, as list or dict(json). 824 | """ 825 | endpoint = 'subscription/users' 826 | version = 'v2' if endpoint not in list(self.versions.keys()) else self.versions[endpoint] 827 | success, rtn = self.call_api(endpoint=endpoint, auth_header=True, request_type=SXTApiCallTypes.GET ) 828 | return success, (rtn if success else [rtn]) 829 | 830 | 831 | def subscription_invite_user(self, role:str = 'member'): 832 | """-------------------- 833 | Creates a subcription invite code (aka joincode). Can join as member, admin, owner. 834 | 835 | Calls and returns data from API: subscription/invite. 836 | Allows an Admin or Owner to generate a joincode for another user, who (after authenticating) 837 | can consume the code and join the subcription at the specified level. 838 | The code is only valid for 24 hours, and assigned role cannot be greater than the creator 839 | (i.e., an Admin cannot generate an Owner code). 840 | 841 | Args: 842 | role (str): Role level to assign the new user. Can be member, admin, or owner. 843 | 844 | Returns: 845 | bool: Success flag (True/False) indicating the api call worked as expected. 846 | object: Response information from the Space and Time network, as list or dict(json). 847 | """ 848 | endpoint = 'subscription/invite' 849 | role = role.upper().strip() 850 | if role not in ['MEMBER','ADMIN','OWNER']: 851 | return False, {'error':'Invites must be either member, admin, or owner. Permissions cannot exceed the invitor.'} 852 | version = 'v2' if endpoint not in list(self.versions.keys()) else self.versions[endpoint] 853 | success, rtn = self.call_api(endpoint=endpoint, auth_header=True, request_type=SXTApiCallTypes.POST, 854 | query_parms={'role':role} ) 855 | return success, (rtn if success else [rtn]) 856 | 857 | 858 | def subscription_join(self, joincode:str): 859 | """-------------------- 860 | Allows the authenticated user to join a subscription by using a valid joincode. 861 | 862 | Calls and returns data from API: subscription/invite/{joinCode}. 863 | Note, joincodes are only valid for 24 hours. 864 | 865 | Args: 866 | joincode (str): Code created by an admin to allow an authenticated user to join their subscription. 867 | 868 | Returns: 869 | bool: Success flag (True/False) indicating the api call worked as expected. 870 | object: Response information from the Space and Time network, as list or dict(json). 871 | """ 872 | endpoint = 'subscription/invite/{joinCode}' 873 | version = 'v2' if endpoint not in list(self.versions.keys()) else self.versions[endpoint] 874 | success, rtn = self.call_api(endpoint=endpoint, auth_header=True, request_type=SXTApiCallTypes.POST, 875 | path_parms= {'joinCode': joincode} ) 876 | return success, (rtn if success else [rtn]) 877 | 878 | 879 | def subscription_leave(self): 880 | """-------------------- 881 | Allows the authenticated user to leave their subscription. 882 | 883 | Calls and returns data from API: subscription/leave. 884 | 885 | Args: 886 | None 887 | 888 | Returns: 889 | bool: Success flag (True/False) indicating the api call worked as expected. 890 | object: Response information from the Space and Time network, as list or dict(json). 891 | """ 892 | endpoint = 'subscription/leave' 893 | version = 'v2' if endpoint not in list(self.versions.keys()) else self.versions[endpoint] 894 | success, rtn = self.call_api(endpoint=endpoint, auth_header=True, request_type=SXTApiCallTypes.POST ) 895 | return success, (rtn if success else [rtn]) 896 | 897 | 898 | def subscription_get_users(self) -> tuple[bool, dict]: 899 | """ 900 | Returns a list of all users in the current subscription. 901 | 902 | Args: 903 | None 904 | 905 | Returns: 906 | bool: Success flag (True/False) indicating the api call worked as expected. 907 | object: Dictionary of User_IDs and User Permission level in the subscription, or error as json. 908 | """ 909 | endpoint = 'subscription/users' 910 | version = 'v2' if endpoint not in list(self.versions.keys()) else self.versions[endpoint] 911 | success, rtn = self.call_api(endpoint=endpoint, auth_header=True, request_type=SXTApiCallTypes.GET ) 912 | if success: rtn = rtn['roleMap'] 913 | return success, rtn 914 | 915 | 916 | def subscription_remove(self, User_ID_to_Remove:str) -> tuple[bool, dict]: 917 | """ 918 | Removes another user from the current user's subscription. Current user must have more authority than the targeted user to remove. 919 | 920 | Args: 921 | User_ID_to_Remove (str): ID of the user to remove from the current user's subscription. 922 | 923 | Returns: 924 | bool: Success flag (True/False) indicating the api call worked as expected. 925 | object: Response information from the Space and Time network, as list or dict(json). 926 | """ 927 | endpoint = 'subscription/remove/{userId}' 928 | version = 'v2' if endpoint not in list(self.versions.keys()) else self.versions[endpoint] 929 | success, rtn = self.call_api(endpoint=endpoint, auth_header=True, request_type=SXTApiCallTypes.POST, 930 | path_parms= {'userId': User_ID_to_Remove} ) 931 | return success, rtn 932 | 933 | 934 | --------------------------------------------------------------------------------