| @@ -1,9 +1,9 @@ | |||||
| VIRTUALENV?=virtualenv-3.7 | |||||
| MODULES=cli.py kleintest.py mtree.py server.py | |||||
| VIRTUALENV?=python3.8 -m venv | |||||
| MODULES=medashare | |||||
| test: fixtures/sample.data.pasn1 fixtures/sample.persona.pasn1 fixtures/sample.mtree | test: fixtures/sample.data.pasn1 fixtures/sample.persona.pasn1 fixtures/sample.mtree | ||||
| (. ./p/bin/activate && \ | (. ./p/bin/activate && \ | ||||
| (ls $(MODULES) | entr sh -c 'python3 -m coverage run -m unittest -f $(basename $(MODULES)) && coverage report -m --omit=p/\*')) | |||||
| (find $(MODULES) -type f | entr sh -c 'python3 -m coverage run -m unittest $(MODULES).tests && coverage report -m --omit=p/\*')) | |||||
| env: | env: | ||||
| $(VIRTUALENV) p | $(VIRTUALENV) p | ||||
| @@ -1,198 +0,0 @@ | |||||
| #!/usr/bin/env python | |||||
| from klein import Klein | |||||
| from klein.interfaces import IKleinRequest | |||||
| from twisted.internet.defer import Deferred | |||||
| from twisted.trial import unittest | |||||
| from twisted.web.http_headers import Headers | |||||
| from zope.interface import implementer | |||||
| from io import StringIO | |||||
| from requests.structures import CaseInsensitiveDict | |||||
| __all__ = [ 'FakeRequests', ] | |||||
| # https://github.com/twisted/twisted/blob/twisted-19.7.0/src/twisted/web/http.py#L664 | |||||
| @implementer(IKleinRequest) | |||||
| class FakeHTTPRequest(object): | |||||
| code = 200 | |||||
| def __init__(self, meth, uri, data): | |||||
| #self.requestHeaders = Headers() | |||||
| self.responseHeaders = Headers() | |||||
| self.content = StringIO(data) | |||||
| self.path = uri | |||||
| self.prepath = [] | |||||
| self.postpath = uri.split(b'/') | |||||
| self.method = meth | |||||
| self.notifications = [] | |||||
| self.finished = False | |||||
| def setHeader(self, name, value): | |||||
| self.responseHeaders.setRawHeaders(name, [value]) | |||||
| def setResponseCode(self, code, message=None): | |||||
| self.code = code | |||||
| def getRequestHostname(self): | |||||
| return b'' | |||||
| def getHost(self): | |||||
| return b'' | |||||
| def isSecure(self): | |||||
| return False | |||||
| def processingFailed(self, failure): | |||||
| self.setResponseCode(500, 'Internal Server Error') | |||||
| #print 'f:', `failure` | |||||
| #print 'b:', failure.getTraceback() | |||||
| def _cleanup(self): | |||||
| for d in self.notifications: | |||||
| d.callback(None) | |||||
| self.notifications = [] | |||||
| def finish(self): | |||||
| if self.finished: # pragma: no cover | |||||
| warnings.warn('Warning! request.finish called twice.', stacklevel=2) | |||||
| self.finished = True | |||||
| self._cleanup() | |||||
| def notifyFinish(self): | |||||
| self.notifications.append(Deferred()) | |||||
| return self.notifications[-1] | |||||
| class FakeRequestsResponse(object): | |||||
| def __init__(self, req): | |||||
| self._req = req | |||||
| self._io = StringIO() | |||||
| req.write = self.write | |||||
| self.status_code = 500 | |||||
| def _finished(self, arg): | |||||
| if arg is not None: # pragma: no cover | |||||
| raise NotImplementedError('cannot handle exceptions yet') | |||||
| self.status_code = self._req.code | |||||
| self.text = self._io.getvalue() | |||||
| self.headers = CaseInsensitiveDict((k.lower(), v[-1]) for k, v in self._req.responseHeaders.getAllRawHeaders()) | |||||
| def write(self, data): | |||||
| self._io.write(data) | |||||
| class FakeRequests(object): | |||||
| '''This class wraps a Klein app into a calling interface that is similar | |||||
| to the requests module for testing apps. | |||||
| Example test: | |||||
| ``` | |||||
| app = Klein() | |||||
| @app.route('/') | |||||
| def home(request): | |||||
| return 'hello' | |||||
| class TestFakeRequests(unittest.TestCase): | |||||
| def setUp(self): | |||||
| self.requests = FakeRequests(app) | |||||
| def test_basic(self): | |||||
| r = self.requests.get('/') | |||||
| self.assertEqual(r.status_code, 200) | |||||
| self.assertEqual(r.text, 'hello') | |||||
| ``` | |||||
| ''' | |||||
| def __init__(self, app): | |||||
| '''Wrap the passed in app as if it was a server for a requests | |||||
| like interface. The URLs expected will not be complete urls.''' | |||||
| self._app = app | |||||
| self._res = app.resource() | |||||
| def _makerequest(self, method, url, data=''): | |||||
| if url[0:1] != b'/': | |||||
| raise ValueError('url must be absolute (start w/ a slash)') | |||||
| req = FakeHTTPRequest('GET', url, data) | |||||
| resp = FakeRequestsResponse(req) | |||||
| req.notifyFinish().addBoth(resp._finished) | |||||
| r = self._res.render(req) | |||||
| return resp | |||||
| def get(self, url): | |||||
| '''Return a response for the passed in url.''' | |||||
| print('ktg:', repr(url)) | |||||
| return self._makerequest('GET', url) | |||||
| def put(self, url, data=''): | |||||
| '''Make a put request to the provied URL w/ the body of data.''' | |||||
| return self._makerequest('PUT', url, data) | |||||
| class TestFakeRequests(unittest.TestCase): | |||||
| def setUp(self): | |||||
| self.putdata = [] | |||||
| app = Klein() | |||||
| @app.route('/') | |||||
| def home(request): | |||||
| request.setHeader('x-testing', 'value') | |||||
| return b'hello' | |||||
| @app.route('/500') | |||||
| def causeerror(request): | |||||
| raise ValueError('random exception') | |||||
| @app.route('/put') | |||||
| def putreq(request): | |||||
| self.putdata.append(request.content.read()) | |||||
| request.setResponseCode(201) | |||||
| return '' | |||||
| @app.route('/404') | |||||
| def notfound(request): | |||||
| request.setResponseCode(404) | |||||
| return 'not found' | |||||
| self.requests = FakeRequests(app) | |||||
| def test_bad(self): | |||||
| self.assertRaises(ValueError, self.requests.get, b'foobar') | |||||
| def test_basic(self): | |||||
| r = self.requests.get(b'/') | |||||
| print(repr(r)) | |||||
| self.assertEqual(r.status_code, 200) | |||||
| self.assertEqual(r.text, 'hello') | |||||
| self.assertEqual(r.headers['X-testing'], 'value') | |||||
| r = self.requests.get('/404') | |||||
| self.assertEqual(r.status_code, 404) | |||||
| r = self.requests.get('/nonexistent') | |||||
| self.assertEqual(r.status_code, 404) | |||||
| r = self.requests.get('/500') | |||||
| self.assertEqual(r.status_code, 500) | |||||
| body = 'body' | |||||
| r = self.requests.put('/put', data=body) | |||||
| self.assertEqual(r.status_code, 201) | |||||
| self.assertEqual(r.text, '') | |||||
| self.assertEqual(''.join(self.putdata), body) | |||||
| @@ -12,7 +12,7 @@ import copy | |||||
| import datetime | import datetime | ||||
| import functools | import functools | ||||
| import hashlib | import hashlib | ||||
| import mock | |||||
| from unittest import mock | |||||
| import os.path | import os.path | ||||
| import pasn1 | import pasn1 | ||||
| import shutil | import shutil | ||||
| @@ -0,0 +1,11 @@ | |||||
| from pydantic import BaseSettings, Field | |||||
| import asyncio | |||||
| __all__ = [ 'Settings' ] | |||||
| class Settings(BaseSettings): | |||||
| db_file: str = Field(description='path to SQLite3 database file') | |||||
| class Config: | |||||
| env_file = '.env' | |||||
| @@ -2,28 +2,34 @@ | |||||
| # Notes: | # Notes: | ||||
| # Python requests: https://2.python-requests.org/en/master/ | # Python requests: https://2.python-requests.org/en/master/ | ||||
| # IRequest interface: https://twistedmatrix.com/documents/current/api/twisted.web.iweb.IRequest.html | |||||
| # IResource interface: https://twistedmatrix.com/documents/current/api/twisted.web.resource.IResource.html | |||||
| # Twisted TDD: https://twistedmatrix.com/documents/current/core/howto/trial.html | |||||
| # Hypothesis: https://hypothesis.readthedocs.io/en/latest/ | |||||
| # Going Async from Flask to Twisted Klein: https://crossbario.com/blog/Going-Asynchronous-from-Flask-to-Twisted-Klein/ | |||||
| # Klein POST docs: https://klein.readthedocs.io/en/latest/examples/handlingpost.html | |||||
| from klein import Klein | |||||
| from kleintest import * | |||||
| from twisted.trial import unittest | |||||
| from twisted.web.iweb import IRequest | |||||
| from cli import _asn1coder, Persona, MDBase, MetaData | |||||
| from .cli import _asn1coder, Persona, MDBase, MetaData | |||||
| from fastapi import FastAPI, APIRouter, Depends | |||||
| from fastapi_restful.cbv import cbv | |||||
| from functools import lru_cache | |||||
| from unittest import mock | |||||
| from . import config | |||||
| import hashlib | import hashlib | ||||
| import mock | |||||
| import os.path | import os.path | ||||
| import shutil | import shutil | ||||
| import tempfile | import tempfile | ||||
| import unittest | |||||
| import uuid | import uuid | ||||
| router = APIRouter() | |||||
| @lru_cache() | |||||
| def get_settings(): # pragma: cover | |||||
| return config.Settings() | |||||
| defaultfile = 'mediaserver.store.pasn1' | defaultfile = 'mediaserver.store.pasn1' | ||||
| @cbv(router) | |||||
| class MEDAServer: | class MEDAServer: | ||||
| settings: config.Settings = Depends(get_settings) | |||||
| def __init__(self, fname): | def __init__(self, fname): | ||||
| self._fname = fname | self._fname = fname | ||||
| self._hashes = {} | self._hashes = {} | ||||
| @@ -42,8 +48,6 @@ class MEDAServer: | |||||
| self._trustedkeys = {} | self._trustedkeys = {} | ||||
| self._objstore = {} | self._objstore = {} | ||||
| app = Klein() | |||||
| def addpubkey(self, pubkey): | def addpubkey(self, pubkey): | ||||
| persona = Persona.from_pubkey(pubkey) | persona = Persona.from_pubkey(pubkey) | ||||
| @@ -58,14 +62,14 @@ class MEDAServer: | |||||
| with open(self._fname, 'w') as fp: | with open(self._fname, 'w') as fp: | ||||
| fp.write(_asn1coder.dumps(obj)) | fp.write(_asn1coder.dumps(obj)) | ||||
| @app.route('/lookup/<hash>') | |||||
| @router.get('/lookup/<hash>') | |||||
| def lookup(self, request, hash): | def lookup(self, request, hash): | ||||
| if hash in self._hashes: | if hash in self._hashes: | ||||
| return | return | ||||
| request.setResponseCode(404) | request.setResponseCode(404) | ||||
| @app.route('/obj/<id>') | |||||
| @router.get('/obj/<id>') | |||||
| def obj_lookup(self, request, id): | def obj_lookup(self, request, id): | ||||
| try: | try: | ||||
| id = uuid.UUID(id) | id = uuid.UUID(id) | ||||
| @@ -84,7 +88,7 @@ class MEDAServer: | |||||
| except AttributeError: | except AttributeError: | ||||
| pass | pass | ||||
| @app.route('/store') | |||||
| @router.post('/store') | |||||
| def storeobj(self, request): | def storeobj(self, request): | ||||
| try: | try: | ||||
| obj = MDBase.decode(request.content.read()) | obj = MDBase.decode(request.content.read()) | ||||
| @@ -104,6 +108,12 @@ class MEDAServer: | |||||
| except Exception: | except Exception: | ||||
| request.setResponseCode(401) | request.setResponseCode(401) | ||||
| def getApp(): | |||||
| app = FastAPI() | |||||
| app.include_router(router) | |||||
| return app | |||||
| # twistd support | # twistd support | ||||
| #medaserver = MEDAServer() | #medaserver = MEDAServer() | ||||
| #resource = medaserver.app.resource | #resource = medaserver.app.resource | ||||
| @@ -132,17 +142,20 @@ def main(): | |||||
| if __name__ == '__main__': # pragma: no cover | if __name__ == '__main__': # pragma: no cover | ||||
| main() | main() | ||||
| class _BaseServerTest(unittest.TestCase): | |||||
| def setUp(self): | |||||
| class _BaseServerTest(unittest.IsolatedAsyncioTestCase): | |||||
| async def asyncSetUp(self): | |||||
| self.app = getApp() | |||||
| # setup test database | |||||
| self.dbtempfile = tempfile.NamedTemporaryFile() | |||||
| # setup settings | |||||
| self.settings = config.Settings(db_file=self.dbtempfile.name, | |||||
| ) | |||||
| d = os.path.realpath(tempfile.mkdtemp()) | d = os.path.realpath(tempfile.mkdtemp()) | ||||
| self.basetempdir = d | self.basetempdir = d | ||||
| self.medaserverfile = os.path.join(self.basetempdir, 'serverstore.pasn1') | self.medaserverfile = os.path.join(self.basetempdir, 'serverstore.pasn1') | ||||
| self.medaserver = MEDAServer(self.medaserverfile) | |||||
| self.requests = FakeRequests(self.medaserver.app) | |||||
| def tearDown(self): | |||||
| shutil.rmtree(self.basetempdir) | |||||
| self.basetempdir = None | |||||
| class _TestCases(_BaseServerTest): | class _TestCases(_BaseServerTest): | ||||
| def test_objlookup(self): | def test_objlookup(self): | ||||
| @@ -0,0 +1,3 @@ | |||||
| from .cli import _TestCononicalCoder, _TestCases | |||||
| from .mtree import Test | |||||
| from .server import _TestCases, _TestPostConfig | |||||
| @@ -1,9 +1,4 @@ | |||||
| urwid | |||||
| -e git+https://www.funkthat.com/gitea/jmg/pasn1.git@01d8efffd7bc3037dcb894ea44dbe959035948c6#egg=pasn1 | |||||
| coverage | |||||
| mock | |||||
| klein | |||||
| cryptography | |||||
| base58 | |||||
| # for kleintest | |||||
| requests | |||||
| # use setup.py for dependancy info | |||||
| -e . | |||||
| -e .[dev] | |||||
| @@ -0,0 +1,42 @@ | |||||
| # python setup.py --dry-run --verbose install | |||||
| import os.path | |||||
| from setuptools import setup, find_packages | |||||
| from distutils.core import setup | |||||
| setup( | |||||
| name='medashare', | |||||
| version='0.1.0', | |||||
| author='John-Mark Gurney', | |||||
| author_email='jmg@funkthat.com', | |||||
| packages=find_packages(), | |||||
| #url='', | |||||
| license='BSD', | |||||
| description='File Metadata sharing, query and storing utility.', | |||||
| #download_url='', | |||||
| long_description=open('README.md').read(), | |||||
| python_requires='>=3.8', | |||||
| install_requires=[ | |||||
| 'base58', | |||||
| 'cryptography', | |||||
| 'databases[sqlite]', | |||||
| 'fastapi', | |||||
| 'fastapi_restful', | |||||
| 'httpx', | |||||
| 'hypercorn', # option, for server only? | |||||
| 'orm', | |||||
| 'pasn1 @ git+https://www.funkthat.com/gitea/jmg/pasn1.git@01d8efffd7bc3037dcb894ea44dbe959035948c6#egg=pasn1', | |||||
| 'pydantic[dotenv]', | |||||
| ], | |||||
| extras_require = { | |||||
| # requests needed for fastpi.testclient.TestClient | |||||
| 'dev': [ 'coverage', 'requests' ], | |||||
| }, | |||||
| entry_points={ | |||||
| 'console_scripts': [ | |||||
| 'medashare = medashare.__main__:main', | |||||
| ] | |||||
| } | |||||
| ) | |||||