diff --git a/bitelab/__init__.py b/bitelab/__init__.py index 3420846..8c426f3 100644 --- a/bitelab/__init__.py +++ b/bitelab/__init__.py @@ -7,11 +7,13 @@ from httpx import AsyncClient, Auth from starlette.status import HTTP_200_OK, HTTP_404_NOT_FOUND, HTTP_401_UNAUTHORIZED from . import config +from . import data import asyncio import gc import socket import sys +import tempfile import unittest # fix up parse_socket_addr for hypercorn @@ -26,13 +28,26 @@ def new_parse_socket_addr(domain, addr): tcp_server.parse_socket_addr = new_parse_socket_addr class BoardManager(object): - board_classes = [ 'cora-z7s' ] + board_class_info = { + 'cora-z7s': { + 'arch': 'arm64-aarch64', + }, + } + + # Naming scheme: + # - + # + boards = { + 'cora-1': { + 'class': 'cora-z7s', + } + } def __init__(self, settings): self._settings = settings def classes(self): - return self.board_classes + return self.board_class_info def unhashable_lru(): def newwrapper(fun): @@ -72,15 +87,22 @@ class BiteAuth(Auth): def get_settings(): return config.Settings() +@unhashable_lru() +def get_data(settings: config.Settings = Depends(get_settings)): + print(repr(settings)) + database = data.databases.Database('sqlite:///' + settings.db_file) + d = data.make_orm(self.database) + return d + @unhashable_lru() def get_boardmanager(settings: config.Settings = Depends(get_settings)): return BoardManager(settings) oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/nonexistent') -def lookup_user(token: str = Depends(oauth2_scheme), settings: config.Settings = Depends(get_settings)): +async def lookup_user(token: str = Depends(oauth2_scheme), data: data.DataWrapper = Depends(get_data)): try: - return settings.apikeytouser(token) + return (await data.APIKey.objects.get(key=token)).user except KeyError: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -101,7 +123,7 @@ async def foo(user: str = Depends(lookup_user), brdmgr: BoardManager = Depends(g @router.get('/board_info') async def foo(user: str = Depends(lookup_user), brdmgr: BoardManager = Depends(get_boardmanager)): - return brdmgr.classes() + return brdmgr.boards @router.get('/') async def foo(board_prio: dict = Depends(board_priority), settings: config.Settings = Depends(get_settings)): @@ -141,18 +163,37 @@ class TestUnhashLRU(unittest.TestCase): # does not return the same object as the first cache self.assertIsNot(cachefun(lsta), cachefun2(lsta)) +async def _setup_data(data): + await data.APIKey.objects.create(user='foo', key='thisisanapikey') + await data.APIKey.objects.create(user='bar', key='anotherlongapikey') + # Note: this will not work under python before 3.8 before # IsolatedAsyncioTestCase was added. The tearDown has to happen # with the event loop running, otherwise the task and other things # do not get cleaned up properly. class TestBiteLab(unittest.IsolatedAsyncioTestCase): - async def get_settings_override(self): - # Note: this gets run on each request. - return config.Settings(apikeyfile="fixtures/api_keys") + def get_settings_override(self): + return self.settings - def setUp(self): + def get_data_override(self): + return self.data + + async def asyncSetUp(self): self.app = getApp() + + # setup test database + self.dbtempfile = tempfile.NamedTemporaryFile() + self.database = data.databases.Database('sqlite:///' + self.dbtempfile.name) + self.data = data.make_orm(self.database) + + await _setup_data(self.data) + + # setup settings + self.settings = config.Settings(db_file=self.dbtempfile.name) + self.app.dependency_overrides[get_settings] = self.get_settings_override + self.app.dependency_overrides[get_data] = self.get_data_override + self.client = AsyncClient(app=self.app, base_url='http://testserver') def tearDown(self): @@ -160,11 +201,6 @@ class TestBiteLab(unittest.IsolatedAsyncioTestCase): asyncio.run(self.client.aclose()) self.client = None - async def test_config(self): - settings = await self.get_settings_override() - self.assertEqual(settings.apikeytouser('thisisanapikey'), 'foo') - self.assertEqual(settings.apikeytouser('anotherlongapikey'), 'bar') - async def test_basic(self): res = await self.client.get('/') self.assertNotEqual(res.status_code, HTTP_404_NOT_FOUND) @@ -180,4 +216,34 @@ class TestBiteLab(unittest.IsolatedAsyncioTestCase): async def test_classes(self): res = await self.client.get('/board_classes', auth=BiteAuth('thisisanapikey')) self.assertEqual(res.status_code, HTTP_200_OK) - self.assertEqual(res.json(), [ 'cora-z7s' ]) + self.assertEqual(res.json(), { 'cora-z7s': { 'arch': 'arm64-aarch64', } }) + + async def test_board_info(self): + res = await self.client.get('/board_info', auth=BiteAuth('thisisanapikey')) + self.assertEqual(res.status_code, HTTP_200_OK) + info = { + 'cora-1': { + 'class': 'cora-z7s', + }, + } + self.assertEqual(res.json(), info) + +class TestData(unittest.IsolatedAsyncioTestCase): + def setUp(self): + # setup temporary directory + self.dbtempfile = tempfile.NamedTemporaryFile() + + self.database = data.databases.Database('sqlite:///' + self.dbtempfile.name) + self.data = data.make_orm(self.database) + + def tearDown(self): + self.data = None + self.database = None + self.dbtempfile = None + + async def test_apikey(self): + data = self.data + self.assertEqual(await data.APIKey.objects.all(), []) + await _setup_data(data) + self.assertEqual((await data.APIKey.objects.get(key='thisisanapikey')).user, 'foo') + self.assertEqual((await data.APIKey.objects.get(key='anotherlongapikey')).user, 'bar') diff --git a/bitelab/config.py b/bitelab/config.py index 4f5b023..abc6f63 100644 --- a/bitelab/config.py +++ b/bitelab/config.py @@ -6,42 +6,7 @@ import aiokq # How to deal w/ private vars: # https://web.archive.org/web/20201113005838/https://github.com/samuelcolvin/pydantic/issues/655 class Settings(BaseSettings): - __slots__ = ('_apikeydb', '_updatetask', '_fp', ) - apikeyfile: str - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - fp = open(self.apikeyfile) - self._sync_updateapikeys(fp) - updtsk = asyncio.create_task(aiokq.run_on_modify(fp, - self._updateapikeys)) - - object.__setattr__(self, '_fp', fp) - object.__setattr__(self, '_updatetask', updtsk) - - # Mark this as covered because coverage says the code isn't run, - # despite the fact that it does get run (and can trigger an - # exception) - def __del__(self): # pragma: no cover - self._updatetask.cancel() - # XXX - looks like task is done before getting here - self._fp.close() - - async def _updateapikeys(self, fp): - self._sync_updateapikeys(fp) - - def _sync_updateapikeys(self, fp): - fp.seek(0) - keydb = {} - for line in fp.readlines(): - user, key = line.split() - keydb[key] = user - - object.__setattr__(self, '_apikeydb', keydb) - - def apikeytouser(self, key): - return self._apikeydb[key] + db_file: str class Config: env_file = ".env" diff --git a/bitelab/data.py b/bitelab/data.py new file mode 100644 index 0000000..04617ba --- /dev/null +++ b/bitelab/data.py @@ -0,0 +1,34 @@ +import databases +import orm +import sqlalchemy + +def _issubclass(a, b): + try: + return issubclass(a, b) + except TypeError: + return False + +class DataWrapper(object): + pass + +def make_orm(database): + metadata = sqlalchemy.MetaData() + + class APIKey(orm.Model): + __tablename__ = 'apikeys' + __database__ = database + __metadata__ = metadata + + user = orm.Text(index=True) + key = orm.Text(primary_key=True) + + engine = sqlalchemy.create_engine(str(database.url)) + metadata.create_all(engine) + + r = DataWrapper() + + lcls = locals() + for i in [ 'engine', 'metadata', ] + [ x for x in lcls if _issubclass(lcls[x], orm.Model) ]: + setattr(r, i, lcls[i]) + + return r diff --git a/setup.py b/setup.py index 6632d68..bb24e13 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,8 @@ setup( 'hypercorn', # option, for server only? 'pydantic[dotenv]', 'aiokq @ git+https://www.funkthat.com/gitea/jmg/aiokq.git' + 'orm', + 'databases[sqlite]', ], extras_require = { 'dev': [ 'coverage' ],