@@ -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 | |||
(. ./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: | |||
$(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 functools | |||
import hashlib | |||
import mock | |||
from unittest import mock | |||
import os.path | |||
import pasn1 | |||
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: | |||
# 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 mock | |||
import os.path | |||
import shutil | |||
import tempfile | |||
import unittest | |||
import uuid | |||
router = APIRouter() | |||
@lru_cache() | |||
def get_settings(): # pragma: cover | |||
return config.Settings() | |||
defaultfile = 'mediaserver.store.pasn1' | |||
@cbv(router) | |||
class MEDAServer: | |||
settings: config.Settings = Depends(get_settings) | |||
def __init__(self, fname): | |||
self._fname = fname | |||
self._hashes = {} | |||
@@ -42,8 +48,6 @@ class MEDAServer: | |||
self._trustedkeys = {} | |||
self._objstore = {} | |||
app = Klein() | |||
def addpubkey(self, pubkey): | |||
persona = Persona.from_pubkey(pubkey) | |||
@@ -58,14 +62,14 @@ class MEDAServer: | |||
with open(self._fname, 'w') as fp: | |||
fp.write(_asn1coder.dumps(obj)) | |||
@app.route('/lookup/<hash>') | |||
@router.get('/lookup/<hash>') | |||
def lookup(self, request, hash): | |||
if hash in self._hashes: | |||
return | |||
request.setResponseCode(404) | |||
@app.route('/obj/<id>') | |||
@router.get('/obj/<id>') | |||
def obj_lookup(self, request, id): | |||
try: | |||
id = uuid.UUID(id) | |||
@@ -84,7 +88,7 @@ class MEDAServer: | |||
except AttributeError: | |||
pass | |||
@app.route('/store') | |||
@router.post('/store') | |||
def storeobj(self, request): | |||
try: | |||
obj = MDBase.decode(request.content.read()) | |||
@@ -104,6 +108,12 @@ class MEDAServer: | |||
except Exception: | |||
request.setResponseCode(401) | |||
def getApp(): | |||
app = FastAPI() | |||
app.include_router(router) | |||
return app | |||
# twistd support | |||
#medaserver = MEDAServer() | |||
#resource = medaserver.app.resource | |||
@@ -132,17 +142,20 @@ def main(): | |||
if __name__ == '__main__': # pragma: no cover | |||
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()) | |||
self.basetempdir = d | |||
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): | |||
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', | |||
] | |||
} | |||
) |