@@ -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', | |||||
] | |||||
} | |||||
) |