commit fefbee83e46c8b15641e19feeabdc193d230a571 Author: retoor Date: Tue Dec 3 14:16:44 2024 +0100 Initial commit. diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..53a035d --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,26 @@ +name: Build Base Application +run-name: Build Base Application +on: [push] + +jobs: + Build: + runs-on: ubuntu-latest + steps: + - name: Check out repository code + uses: actions/checkout@v4 + - name: Update repo + run: git pull + - name: List files in the repository + run: | + ls ${{ gitea.workspace }} + - name: Update apt + run: apt update + - name: Installing system dependencies + run: apt install python3 python3-pip python3-venv make -y + - name: Run Make + - run: make + - run: git add . + - run: git config --global user.email "bot@molodetz.com" + - run: git config --global user.name "bot" + - run: git commit -a -m "Automated update of Base Application package." + - run: git push diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5305b94 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.vscode +.history +.venv +__pycache__ +.trigger-2024-12-02 13:37:42 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8f6c50d --- /dev/null +++ b/Makefile @@ -0,0 +1,40 @@ +BIN = ./.venv/bin/ +PYTHON = ./.venv/bin/python +PIP = ./.venv/bin/pip + +APP_NAME=app + +all: install build test + +ensure_repo: + -@git init + +ensure_env: ensure_repo + -@python3 -m venv .venv + +install: ensure_env + $(PIP) install -e . + +format: ensure_env + $(PIP) install shed + . $(BIN)/activate && shed + +build: ensure_env + $(MAKE) format + $(PIP) install build + $(PYTHON) -m build + +serve: ensure_env + $(BIN)serve --host=0.0.0.0 --port=8888 + +run: ensure_env + $(BIN)zhurnal "make serve" "make bench" + +bench: ensure_env + $(BIN)bench --url=http://127.0.0.1:8888/ + +cli: ensure_env + $(BIN)cli --url=wss://localhost:8888 + +test: ensure_env + $(PYTHON) -m unittest $(APP_NAME).tests diff --git a/dist/app-1.0.0-py3-none-any.whl b/dist/app-1.0.0-py3-none-any.whl new file mode 100644 index 0000000..4fafb46 Binary files /dev/null and b/dist/app-1.0.0-py3-none-any.whl differ diff --git a/dist/app-1.0.0.tar.gz b/dist/app-1.0.0.tar.gz new file mode 100644 index 0000000..a91d9da Binary files /dev/null and b/dist/app-1.0.0.tar.gz differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..07de284 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..28d1c3d --- /dev/null +++ b/setup.cfg @@ -0,0 +1,29 @@ +[metadata] +name = app +version = 1.0.0 +description = Base application +author = retoor +author_email = retoor@molodetz.nl +license = MIT +long_description = file: README.md +long_description_content_type = text/markdown + +[options] +packages = find: +package_dir = + = src +python_requires = >=3.7 +install_requires = + aiohttp + dataset + zhurnal @ git+https://retoor.molodetz.nl/retoor/zhurnal.git@main + +[options.packages.find] +where = src + +[options.entry_points] +console_scripts = + serve = app.__main__:main + bench = app.cli:cli_bench + cli = app.cli:main + repl = app.repl:repl diff --git a/src/app.egg-info/PKG-INFO b/src/app.egg-info/PKG-INFO new file mode 100644 index 0000000..82bc7ec --- /dev/null +++ b/src/app.egg-info/PKG-INFO @@ -0,0 +1,12 @@ +Metadata-Version: 2.1 +Name: app +Version: 1.0.0 +Summary: Base application +Author: retoor +Author-email: retoor@molodetz.nl +License: MIT +Requires-Python: >=3.7 +Description-Content-Type: text/markdown +Requires-Dist: aiohttp +Requires-Dist: dataset +Requires-Dist: zhurnal@ git+https://retoor.molodetz.nl/retoor/zhurnal.git@main diff --git a/src/app.egg-info/SOURCES.txt b/src/app.egg-info/SOURCES.txt new file mode 100644 index 0000000..33692c7 --- /dev/null +++ b/src/app.egg-info/SOURCES.txt @@ -0,0 +1,17 @@ +pyproject.toml +setup.cfg +src/app/__init__.py +src/app/__main__.py +src/app/app.py +src/app/args.py +src/app/cli.py +src/app/kim.py +src/app/repl.py +src/app/server.py +src/app/tests.py +src/app.egg-info/PKG-INFO +src/app.egg-info/SOURCES.txt +src/app.egg-info/dependency_links.txt +src/app.egg-info/entry_points.txt +src/app.egg-info/requires.txt +src/app.egg-info/top_level.txt \ No newline at end of file diff --git a/src/app.egg-info/dependency_links.txt b/src/app.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/app.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/app.egg-info/entry_points.txt b/src/app.egg-info/entry_points.txt new file mode 100644 index 0000000..ed722fe --- /dev/null +++ b/src/app.egg-info/entry_points.txt @@ -0,0 +1,5 @@ +[console_scripts] +bench = app.cli:cli_bench +cli = app.cli:main +repl = app.repl:repl +serve = app.__main__:main diff --git a/src/app.egg-info/requires.txt b/src/app.egg-info/requires.txt new file mode 100644 index 0000000..d9ca351 --- /dev/null +++ b/src/app.egg-info/requires.txt @@ -0,0 +1,3 @@ +aiohttp +dataset +zhurnal@ git+https://retoor.molodetz.nl/retoor/zhurnal.git@main diff --git a/src/app.egg-info/top_level.txt b/src/app.egg-info/top_level.txt new file mode 100644 index 0000000..b80f0bd --- /dev/null +++ b/src/app.egg-info/top_level.txt @@ -0,0 +1 @@ +app diff --git a/src/app/__init__.py b/src/app/__init__.py new file mode 100644 index 0000000..7e315cc --- /dev/null +++ b/src/app/__init__.py @@ -0,0 +1,9 @@ +import logging + +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) + +log = logging.getLogger(__name__) diff --git a/src/app/__main__.py b/src/app/__main__.py new file mode 100644 index 0000000..e7713f8 --- /dev/null +++ b/src/app/__main__.py @@ -0,0 +1,13 @@ +from aiohttp import web +from .app import create_app +from .args import parse_args +from .server import serve + +def main(): + args = parse_args() + serve(args.host, args.port) + + +if __name__ == '__main__': + main() + diff --git a/src/app/app.py b/src/app/app.py new file mode 100644 index 0000000..25277e2 --- /dev/null +++ b/src/app/app.py @@ -0,0 +1,210 @@ +from aiohttp import web +import time +from . import log +import json +import uuid +import dataset + +def get_timestamp(): + from datetime import datetime + now = datetime.now() + formatted_datetime = now.strftime("%Y-%m-%d %H:%M:%S") + return formatted_datetime + +class BaseApplication(web.Application): + + def __init__(self, username = None, password=None, cookie_name=None,session=None, *args, **kwargs): + self.cookie_name = cookie_name or str(uuid.uuid4()) + self.username = username + self.password = password + self.session = session or {} + middlewares = kwargs.pop("middlewares",[]) + middlewares.append(self.request_middleware) + middlewares.append(self.base64_auth_middleware) + middlewares.append(self.session_middleware) + super().__init__(*args, **kwargs) + + + async def authenticate(self, username, password): + return self.username == username and self.password == password + + @web.middleware + async def base64_auth_middleware(request, handler): + auth_header = request.headers.get("Authorization") + if not self.username: + return await handler(request) + if not auth_header or not auth_header.startswith("Basic "): + return web.Response( + status=401, + text="Unauthorized", + headers={"WWW-Authenticate": 'Basic realm="Restricted"'} + ) + + try: + encoded_credentials = auth_header.split(" ", 1)[1] + decoded_credentials = base64.b64decode(encoded_credentials).decode("utf-8") + username, password = decoded_credentials.split(":", 1) + except (ValueError, base64.binascii.Error): + return web.Response(status=400, text="Invalid Authorization Header") + + if not await self.authenticate(username, password): + return web.Response( + status=401, + text="Invalid Credentials", + headers={"WWW-Authenticate": 'Basic realm="Restricted"'} + ) + + return await handler(request) + @web.middleware + async def request_middleware(self, request, handler): + time_start = time.time() + created = get_timestamp() + request = await handler(request) + time_end = time.time() + await self.insert("http_access",dict( + created=created, + path=request.path, + duration=time_end - time_start, + )) + return request + + @web.middleware + async def session_middleware(self,request, handler): + # Process the request and get the response + cookies = request.cookies + session_id = cookies.get(self.cookie_name, None) + setattr(request,"session", self.session.get(session_id,{})) + response = await handler(request) + + if not session_id: + session_id = str(uuid.uuid4()) + response.set_cookie( + self.cookie_name, + session_id, + max_age=3600, + httponly=True + ) + return response + +class WebDbApplication(BaseApplication): + + def __init__(self, db=None, db_web=False,db_path="sqlite:///:memory:",*args, **kwargs): + super().__init__(*args, **kwargs) + self.db_web = db_web + self.db_path = db_path + self.db = db or dataset.connect(self.db_path) + if not self.db_web: + return + self.router.add_post("/insert", self.insert_handler) + self.router.add_post("/update", self.update_handler) + self.router.add_post("/upsert", self.upsert_handler) + self.router.add_post("/find", self.find_handler) + self.router.add_post("/find_one", self.find_one_handler) + self.router.add_post("/delete", self.delete_handler) + + async def insert_handler(self, request): + obj = await request.json() + response = await self.insert(request.get("table"), request.get("data")) + return web.json_response(response) + + async def update_handler(self, request): + obj = await request.json() + response = await self.update(request.get('table'), request.get('data'), request.get('where',{})) + return web.json_response(response) + + async def upsert_handler(self, request): + obj = await request.json() + response = await self.upsert(request.get('table'), request.get('data'), request.get('keys',[])) + return web.json_response(response) + + async def find_handler(self, request): + obj = await request.json() + response = await self.find(request.get('table'), requesst.get('where',{})) + return web.json_response(response) + + async def find_one_handler(self, request): + obj = await request.json() + response = await self.find_one(request.get('table'), requesst.get('where',{})) + return web.json_response(response) + + async def delete_handler(self, request): + obj = await request.json() + response = await self.delete(request.get('table'), requesst.get('where',{})) + return web.json_response(response) + + async def set(self, key, value): + value = json.dumps(value, default=str) + self.db['kv'].upsert(dict(key=key,value=value),['key']) + + async def get(self, key, default=None): + record = self.db['kv'].find_one(key=key) + if record: + return json.loads(record.get('value','null')) + return default + + async def insert(self, table_name, data): + return self.db[table_name].insert(data) + + async def update(self, table_name, data, where): + return self.db[table_name].update(data,where) + + async def upsert(self, table_name, data, keys): + return self.db[table_name].upsert(data,keys or []) + + async def find(self, table_name, filters): + if not filters: + filters = {} + return [dict(record) for record in self.db[table_name].find(**filters)] + + async def find_one(self, table_name, filters): + if not filters: + filters = {} + try: + return dict(self.db[table_name].find_one(**filters)) + except ValueError: + return None + + async def delete(self, table_name, where): + return self.db[table_name].delete(**where) + + + + +class Application(WebDbApplication): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.on_startup.append(self.on_startup_task) + self.router.add_get("/", self.index_handler) + self.request_count = 0 + self.time_started = time.time() + self.running_since = None + + @property + def uptime(self): + return time.time() - self.time_started + + async def on_startup_task(self, app): + log.debug("App starting.") + self.running_since = get_timestamp() + + async def inc_request_count(self): + request_count = await self.get("root_request_count",0) + request_count += 1 + await self.set("root_request_count", request_count) + return request_count + + async def index_handler(self,request): + + return web.json_response(dict( + request_count=await self.inc_request_count(), + timestamp=get_timestamp(), + uptime=self.uptime, + running_since=self.running_since + ),content_type="application/json") + + +def create_app(*args, **kwargs): + app = Application(*args, **kwargs) + + return app diff --git a/src/app/args.py b/src/app/args.py new file mode 100644 index 0000000..57cb6f0 --- /dev/null +++ b/src/app/args.py @@ -0,0 +1,36 @@ +import argparse + +def parse_args(): + parser = argparse.ArgumentParser( + description="Async web service" + ) + + parser.add_argument( + '--host', + type=str, + required=False, + default="0.0.0.0", + help='Host to serve on. Default: 0.0.0.0.' + ) + + parser.add_argument( + '--port', + type=int, + required=False, + default=8888, + help='Port to serve on Default: 8888.' + ) + + + + parser.add_argument( + '--url', + type=str, + default="http://localhost:8888", + required=False, + help='Base URL.' + ) + + return parser.parse_args() + + diff --git a/src/app/cli.py b/src/app/cli.py new file mode 100644 index 0000000..d5980af --- /dev/null +++ b/src/app/cli.py @@ -0,0 +1,52 @@ +import asyncio +from aiohttp import ClientSession +from .args import parse_args +import time +from . import log + + + + +async def cli_client(url): + while True: + sentence = input("> ") + async with ClientSession() as session: + async with session.post("http://localhost:8080",json=sentence) as response: + try: + print(await response.json()) + except Exception as ex: + print(ex) + print(await response.text()) + +async def bench(url): + index = 0 + while True: + index += 1 + try: + time_start = time.time() + + async with ClientSession() as session: + async with session.get(url) as response: + print(await response.text()) + #print(await response.json()) + time_end = time.time() + print("Request {}. Duration: {}".format(index,time_end-time_start)) + except Exception as ex: + log.exception(ex) + await asyncio.sleep(1) + +def cli_bench(): + args = parse_args() + asyncio.run(bench(args.url)) + + +def main(): + args = parse_args() + asyncio.run(cli_client(args.url)) + +if __name__ == '__main__': + main() + + + + diff --git a/src/app/kim.py b/src/app/kim.py new file mode 100644 index 0000000..df61755 --- /dev/null +++ b/src/app/kim.py @@ -0,0 +1,93 @@ +#!/usr/bin/python3 + +import sys +import shutil +from readchar import readkey + + +def text_editor(init='', prompt=''): + ''' + Allow user to edit a line of text complete with support for line wraps + and a cursor | you can move back and forth with the arrow keys. + init = initial text supplied to edit + prompt = Decoration presented before the text (not editable and not returned) + ''' + + term_width = shutil.get_terminal_size()[0] + ptr = len(init) + text = list(init) + prompt = list(prompt) + + c = 0 + while True: + if ptr and ptr > len(text): + ptr = len(text) + + copy = prompt + text.copy() + if ptr < len(text): + copy.insert(ptr + len(prompt), '|') + + # Line wraps support: + if len(copy) > term_width: + cut = len(copy) + 3 - term_width + if ptr > len(copy) / 2: + copy = ['<'] * 3 + copy[cut:] + else: + copy = copy[:-cut] + ['>'] * 3 + + + # Display current line + print('\r' * term_width + ''.join(copy), end=' ' * (term_width - len(copy))) + + + # Read new character into c + if c in (53, 54): + # Page up/down bug + c = readkey() + if c == '~': + continue + else: + c = readkey() + + if len(c) > 1: + # Control Character + c = ord(c[-1]) + if c == 68: # Left + ptr -= 1 + elif c == 67: # Right + ptr += 1 + elif c == 53: # PgDn + ptr -= term_width // 2 + elif c == 54: # PgUp + ptr += term_width // 2 + elif c == 70: # End + ptr = len(text) + elif c == 72: # Home + ptr = 0 + else: + print("\nUnknown control character:", c) + print("Press ctrl-c to quit.") + continue + if ptr < 0: + ptr = 0 + if ptr > len(text): + ptr = len(text) + + else: + num = ord(c) + if num in (13, 10): # Enter + print() + return ''.join(text) + elif num == 127: # Backspace + if text: + text.pop(ptr - 1) + ptr -= 1 + elif num == 3: # Ctrl-C + sys.exit(1) + else: + # Insert normal character into text. + text.insert(ptr, c) + ptr += 1 + +if __name__ == "__main__": + print("Result =", text_editor('Edit this text', prompt="Prompt: ")) diff --git a/src/app/repl.py b/src/app/repl.py new file mode 100644 index 0000000..468d723 --- /dev/null +++ b/src/app/repl.py @@ -0,0 +1,13 @@ +import code + + +def repl(**kwargs): + varlables = {} + variables.update(globals().copy()) + variables.update(locals()) + variables.update(kwargs) + code.interact(local=variables) + +if __name__ == "__main__": + + repl() diff --git a/src/app/server.py b/src/app/server.py new file mode 100644 index 0000000..647f214 --- /dev/null +++ b/src/app/server.py @@ -0,0 +1,17 @@ +from aiohttp import web +from .app import create_app +from . import log +import time + +def serve(host="0.0.0.0",port=8888): + app = create_app() + log.info("Serving on {}:{}".format(host,port)) + while True: + try: + web.run_app(app,host=host,port=port) + except KeyboardInterrupt: + break + except Exception as ex: + log.exception(ex) + log.error("Server crashed. Waiting one second before reboot") + time.sleep(1) diff --git a/src/app/tests.py b/src/app/tests.py new file mode 100644 index 0000000..914c583 --- /dev/null +++ b/src/app/tests.py @@ -0,0 +1,20 @@ +from .app import WebDbApplication +import unittest +import asyncio + + + +class WebDbApplicationTestCase(unittest.IsolatedAsyncioTestCase): + + + def setUp(self): + self.db = WebDbApplication() + + async def test_insert(self): + + self.assertEqual(await self.db.insert("test",dict(test=True)), 1) + self.assertEqual(len(await self.db.find("test",dict(test=True, id=1))), 1) + + async def test_find(self): + print(await self.db.find("test",None)) + # self.assertEqual(len(await self.db.find("test",dict(test=True, id=1))), 1)