├── LumosWeb ├── __init__.py ├── static │ └── styles.css ├── response.py ├── middleware.py ├── cli.py ├── api.py └── orm.py ├── test.db ├── .gitignore ├── static └── main.css ├── templates ├── index.html ├── error.html └── index.md ├── .devcontainer ├── Dockerfile ├── docker-compose.yml └── devcontainer.json ├── conftest.py ├── setup.py ├── app.py ├── test_orm.py ├── README.md └── test_lumos.py /LumosWeb/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sddilora/LumosWeb/HEAD/test.db -------------------------------------------------------------------------------- /LumosWeb/static/styles.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sddilora/LumosWeb/HEAD/LumosWeb/static/styles.css -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/lumos_venv 2 | Notes.md 3 | 4 | .vscode 5 | 6 | **/__pycache__/ 7 | **/.pytest_cache/ 8 | **/.coverage 9 | **/.coveragerc 10 | 11 | LumosWeb.egg-info/ 12 | build/ 13 | dist/ -------------------------------------------------------------------------------- /static/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Arial, sans-serif; 3 | background-color: #f2f2f2; 4 | margin: 0; 5 | padding: 0; 6 | } 7 | 8 | .container { 9 | max-width: 600px; 10 | margin: 0 auto; 11 | padding: 20px; 12 | background-color: #fff; 13 | border-radius: 8px; 14 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); 15 | } 16 | 17 | h1 { 18 | color: #20786f; 19 | text-align: center; 20 | } 21 | 22 | p { 23 | color: #666; 24 | text-align: center; 25 | } 26 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ title }} 5 | 6 | 7 | 8 | 9 |
10 |

Welcome to {{ name }} !

11 |

This is the example page of LumosWeb, a fantastic web framework.

12 |

You can create your templates in templates/ directory.

13 |

Like This one :)

14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/python:0-3.11 2 | 3 | ENV PYTHONUNBUFFERED 1 4 | 5 | # [Optional] If your requirements rarely change, uncomment this section to add them to the image. 6 | # COPY requirements.txt /tmp/pip-tmp/ 7 | # RUN pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt \ 8 | # && rm -rf /tmp/pip-tmp 9 | 10 | # [Optional] Uncomment this section to install additional OS packages. 11 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 12 | # && apt-get -y install --no-install-recommends 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import os 3 | 4 | from LumosWeb.api import API 5 | from LumosWeb.orm import Database, Table, Column, ForeignKey 6 | 7 | @pytest.fixture 8 | def api(): 9 | return API() 10 | 11 | 12 | @pytest.fixture 13 | def client(api): 14 | return api.test_session() 15 | 16 | @pytest.fixture 17 | def db(): 18 | DB_PATH = "./test.db" 19 | if os.path.exists(DB_PATH): 20 | os.remove(DB_PATH) 21 | db = Database(DB_PATH) 22 | return db 23 | 24 | 25 | @pytest.fixture 26 | def Author(): 27 | class Author(Table): 28 | name = Column(str) 29 | age = Column(int) 30 | 31 | return Author 32 | 33 | 34 | @pytest.fixture 35 | def Book(Author): 36 | class Book(Table): 37 | title = Column(str) 38 | published = Column(bool) 39 | author = ForeignKey(Author) 40 | 41 | return Book -------------------------------------------------------------------------------- /templates/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{title}} 5 | 45 | 46 | 47 |
48 |

Hey, who turned off the lights? :(

49 |

We are encountering an error:

50 |

{{name}}

51 |
52 | 53 | 54 | -------------------------------------------------------------------------------- /LumosWeb/response.py: -------------------------------------------------------------------------------- 1 | import json 2 | from webob import Response as WebObResponse 3 | 4 | class Response: 5 | def __init__(self): 6 | self.text = None 7 | self.json = None 8 | self.html = None 9 | self.status_code = 200 10 | self.body = b'' 11 | self.content_type = None 12 | 13 | def __call__(self, environ, start_response): 14 | self.set_body_and_content_type() 15 | 16 | response = WebObResponse( 17 | body = self.body, content_type=self.content_type, status=self.status_code 18 | ) 19 | return response(environ, start_response) 20 | 21 | def set_body_and_content_type(self): 22 | if self.json is not None: 23 | self.body = json.dumps(self.json).encode("utf-8") 24 | self.content_type = "application/json" 25 | 26 | if self.text is not None: 27 | self.body = self.text 28 | self.content_type = "text/plain" 29 | 30 | if self.html is not None: 31 | self.body = self.html.encode() 32 | self.content_type = "text/html" 33 | -------------------------------------------------------------------------------- /LumosWeb/middleware.py: -------------------------------------------------------------------------------- 1 | from webob import Request 2 | class Middleware: 3 | def __init__(self, app): 4 | self.app = app 5 | 6 | # Since middlewares are the first entrypoint to the app, they are now called by a web server 7 | # instead of the app itself. So, we need to add a __call__ method to the Middleware class. 8 | def __call__(self, environ, start_response): 9 | req = Request(environ) 10 | resp = self.handle_request(req) 11 | return resp(environ, start_response) 12 | 13 | # we wrapped the given middleware class around the current app. 14 | def add(self, middleware_cls): 15 | self.app = middleware_cls(self.app) 16 | 17 | # we added a process_request and process_response method to the Middleware class. 18 | def process_request(self, req): 19 | pass 20 | 21 | def process_response(self, req, resp): 22 | pass 23 | 24 | # The method that handles incoming requests 25 | def handle_request(self, req): 26 | self.process_request(req) 27 | resp = self.app.handle_request(req) 28 | self.process_response(req, resp) 29 | return resp -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | app: 5 | build: 6 | context: .. 7 | dockerfile: .devcontainer/Dockerfile 8 | 9 | volumes: 10 | - ../..:/workspaces:cached 11 | 12 | # Overrides default command so things don't shut down after the process ends. 13 | command: sleep infinity 14 | 15 | # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. 16 | network_mode: service:db 17 | 18 | # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. 19 | # (Adding the "ports" property to this file will not forward from a Codespace.) 20 | 21 | db: 22 | image: postgres:latest 23 | restart: unless-stopped 24 | volumes: 25 | - postgres-data:/var/lib/postgresql/data 26 | environment: 27 | POSTGRES_USER: postgres 28 | POSTGRES_DB: postgres 29 | POSTGRES_PASSWORD: postgres 30 | 31 | # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally. 32 | # (Adding the "ports" property to this file will not forward from a Codespace.) 33 | 34 | volumes: 35 | postgres-data: 36 | -------------------------------------------------------------------------------- /LumosWeb/cli.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | from LumosWeb.api import API # Import the API class 4 | 5 | 6 | def main(): 7 | if len(sys.argv) < 4 or sys.argv[1] != "--app": 8 | print("Usage: Lumosweb --app run") 9 | return 10 | 11 | app_module = sys.argv[2] 12 | app_path = os.path.abspath(os.path.join(os.getcwd(), app_module + ".py")) 13 | app_directory = os.path.dirname(app_path) 14 | 15 | if os.path.exists(app_path): 16 | sys.path.append(app_directory) # Add app directory to the system path 17 | 18 | with open(app_path, "r") as file: 19 | code = compile(file.read(), app_path, "exec") 20 | namespace = {} 21 | exec(code, namespace) 22 | app = None 23 | for obj in namespace.values(): 24 | if isinstance(obj, API): 25 | app = obj 26 | break 27 | if app is not None: 28 | app.run() 29 | else: 30 | raise AttributeError(f"No instance of 'API' found in module: {app_module}") 31 | else: 32 | raise ImportError(f"Failed to import app module: {app_module}") 33 | 34 | if __name__ == "__main__": 35 | main() 36 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/postgres 3 | { 4 | "name": "Python 3 & PostgreSQL", 5 | "dockerComposeFile": "docker-compose.yml", 6 | "service": "app", 7 | "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", 8 | "customizations": { 9 | "vscode": { 10 | "extensions": [ 11 | "eamodio.gitlens", 12 | "esbenp.prettier-vscode" 13 | ] 14 | } 15 | } 16 | 17 | // Features to add to the dev container. More info: https://containers.dev/features. 18 | // "features": {}, 19 | 20 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 21 | // This can be used to network with other containers or the host. 22 | // "forwardPorts": [5000, 5432], 23 | 24 | // Use 'postCreateCommand' to run commands after the container is created. 25 | // "postCreateCommand": "pip install --user -r requirements.txt", 26 | 27 | // Configure tool-specific properties. 28 | // "customizations": {}, 29 | 30 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 31 | // "remoteUser": "root" 32 | } 33 | -------------------------------------------------------------------------------- /templates/index.md: -------------------------------------------------------------------------------- 1 | # Example Requests 2 | 3 | ```http 4 | POST /topics HTTP/1.1 5 | Host: localhost:8080 6 | Content-Type: application/json 7 | ``` 8 | 9 | | Parameter | Type | Value | 10 | | :--------: | :-------: | :-------------------------: | 11 | | `title` | `string` | "Example Topic" | 12 | | `title` | `string` | "Example Topic2" | 13 | 14 | ```go 15 | package main 16 | import "fmt" 17 | func main() { 18 | fmt.Println("hello world") 19 | } 20 | ``` 21 | 22 | ```json 23 | { 24 | "title": "Example Topic" 25 | } 26 | ``` 27 | 28 | ```python 29 | print("Hello World") 30 | a = 4 31 | ``` 32 | 33 | Left Header | Center Header | Right Header 34 | :----------- | :-------------: | ------------: 35 | Conent Cell | Content Cell | Content Cell 36 | 37 | ```python 38 | print('hellow world') 39 | ``` 40 | 41 | ```js 42 | console.log('hello world') 43 | var str = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." 44 | ``` 45 | 46 | ~~~ 47 | a one-line code block 48 | ~~~ 49 | 50 | [gooogle](www.google.com) 51 | 52 | > Lorem ipsum dolor sit amet 53 | 54 | * A list item. -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import io 5 | import os 6 | 7 | from setuptools import find_packages, setup 8 | 9 | # Package meta-data. 10 | NAME = "LumosWeb" 11 | DESCRIPTION = "LumosWeb is web framework, simple and effective usage" 12 | EMAIL = "sumeyyedilaradogan@gmail.com" 13 | AUTHOR = "Sddilora" 14 | REQUIRES_PYTHON = ">=3.6.0" 15 | VERSION = "1.2.0" 16 | 17 | # Which packages are required for this module to be executed? 18 | REQUIRED = [ 19 | "Jinja2==3.1.2", 20 | "parse==1.19.0", 21 | "requests==2.31.0", 22 | "requests-wsgi-adapter==0.4.1", 23 | "WebOb==1.8.7", 24 | "whitenoise==6.4.0", 25 | "Markdown==3.4.3", 26 | "Pygments==2.15.1" 27 | ] 28 | 29 | here = os.path.abspath(os.path.dirname(__file__)) 30 | 31 | # Import the README and use it as the long-description. 32 | # Note: this will only work if 'README.md' is present in your MANIFEST.in file! 33 | try: 34 | with io.open(os.path.join(here, "README.md"), encoding="utf-8") as f: 35 | long_description = "\n" + f.read() 36 | except FileNotFoundError: 37 | long_description = DESCRIPTION 38 | 39 | # Load the package's __version__.py module as a dictionary. 40 | about = {} 41 | if not VERSION: 42 | project_slug = NAME.lower().replace("-", "_").replace(" ", "_") 43 | with open(os.path.join(here, project_slug, "__version__.py")) as f: 44 | exec(f.read(), about) 45 | else: 46 | about["__version__"] = VERSION 47 | 48 | 49 | # Where the magic happens: 50 | setup( 51 | name=NAME, 52 | version=about["__version__"], 53 | description=DESCRIPTION, 54 | long_description=long_description, 55 | long_description_content_type="text/markdown", 56 | author=AUTHOR, 57 | author_email=EMAIL, 58 | python_requires=REQUIRES_PYTHON, 59 | packages=find_packages(exclude=["test_*"]), 60 | install_requires=REQUIRED, 61 | include_package_data=True, 62 | license="MIT", 63 | classifiers=[ 64 | "Programming Language :: Python :: 3.6", 65 | ], 66 | setup_requires=["wheel"], 67 | entry_points={ 68 | 'console_scripts': [ 69 | 'Lumosweb = LumosWeb.cli:main', 70 | ]}, 71 | ) -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from LumosWeb.api import API 2 | from LumosWeb.middleware import Middleware 3 | 4 | app = API() 5 | 6 | @app.route("/", allowed_methods=["GET", "post"]) 7 | def about(request, response): 8 | response.text = "Lights are on!" 9 | 10 | @app.route("/home", allowed_methods=["get"]) 11 | def home(request, response): 12 | if request.method == "get" or "GET": ### This is a bug, as we are not checking for the request method TODO: Fix this 13 | response.text = "Hello from the HOME page" 14 | else: 15 | raise AttributeError("Method not allowed.",request.method) 16 | 17 | @app.route("/hello/{name}", allowed_methods=["get", "post"]) 18 | def greeting(request, response, name): 19 | response.text = f"What are you doing here, {name}" 20 | 21 | @app.route("/book/{title}/page/{page:d}", allowed_methods=["get", "post"]) 22 | def book(request, response, title, page): 23 | response.text = f"You are reading the Book: {title}, and you were on Page: {page}" 24 | 25 | @app.route("/sum/{num_1:d}/{num_2:d}", allowed_methods=["get", "post"]) 26 | def sum(request, response, num_1, num_2): 27 | total = int(num_1) + int(num_2) 28 | response.text = f"{num_1} + {num_2} = {total}" 29 | 30 | @app.route("/book", allowed_methods=["get", "post"]) 31 | class BooksResource: 32 | def get(self, req, resp): 33 | resp.text = "Books Page" 34 | 35 | def post(self, req, resp): 36 | resp.text = "Endpoint to create a book" 37 | 38 | # @app.route("/template") 39 | # def template_handler(req, resp): 40 | # resp.body = app.template("index.html", context={"name": "LumosWeb", "title":"Lights are on!"}).encode() # Now we dont need to encode the body, as we are doing it in the Response class 41 | 42 | ## Adding a route without a decorator 43 | def handler(req, resp): 44 | resp.text = "We don't have to use decorators!" 45 | 46 | app.add_route("/sample", handler, allowed_methods=["get", "post"]) 47 | 48 | # To handle exceptions 49 | def custom_exception_handler(request, response, exception_cls): 50 | response.body = app.template("error.html", context={"name": exception_cls, "title":"Lights cannot be turned on!"}).encode() 51 | 52 | 53 | app.add_exception_handler(custom_exception_handler) 54 | 55 | @app.route("/exception", allowed_methods=["get", "post"]) 56 | def exception_throwing_handler(request, response): 57 | raise AssertionError("Sorry, This handler should not be used") 58 | 59 | # custom middleware 60 | class SimpleCustomMiddleware(Middleware): 61 | def process_request(self, req): 62 | print("Processing request", req.url) 63 | 64 | def process_response(self, req, resp): 65 | print("Processing response", req.url) 66 | 67 | app.add_middleware(SimpleCustomMiddleware) 68 | 69 | @app.route("/template", allowed_methods=["get", "post"]) 70 | def template_handler(req, resp): 71 | resp.html = app.template("index.html", context={"name": "LumosWeb", "title":"Lights are on!"}) 72 | 73 | @app.route("/json", allowed_methods=["get", "post"]) 74 | def json_handler(req, resp): 75 | resp.json = {"name": "LumosData", "type":"JSON"} 76 | 77 | @app.route("/text", allowed_methods=["get", "post"]) 78 | def text_handler(req, resp): 79 | resp.text = "This is a plain text" 80 | 81 | @app.route("/serve/md-files", allowed_methods=["get", "post"]) 82 | def about(request, response): 83 | response.html = app.template("index.md") 84 | 85 | app.run() -------------------------------------------------------------------------------- /test_orm.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | 3 | import pytest 4 | 5 | def test_create_db(db): 6 | assert isinstance(db.conn, sqlite3.Connection) 7 | assert db.tables == [] 8 | 9 | def test_define_tables(Author, Book): 10 | assert Author.name.type == str 11 | assert Book.author.table == Author 12 | 13 | assert Author.name.sql_type == "TEXT" 14 | assert Author.age.sql_type == "INTEGER" 15 | 16 | def test_create_tables(db, Author, Book): 17 | db.create(Author) 18 | db.create(Book) 19 | 20 | assert Author._get_create_sql() == "CREATE TABLE IF NOT EXISTS author (id INTEGER PRIMARY KEY AUTOINCREMENT, age INTEGER, name TEXT);" 21 | assert Book._get_create_sql() == "CREATE TABLE IF NOT EXISTS book (id INTEGER PRIMARY KEY AUTOINCREMENT, author_id INTEGER, published INTEGER, title TEXT);" 22 | 23 | for table in ("author", "book"): 24 | assert table in db.tables 25 | 26 | def test_create_author_instance(db, Author): 27 | db.create(Author) 28 | author = Author(name="J. K. Rowling", age=54) 29 | assert author.name == "J. K. Rowling" 30 | assert author.age == 54 31 | assert author.id is None 32 | 33 | def test_save_author_instance(db, Author): 34 | db.create(Author) 35 | 36 | rowling = Author(name="J. K. Rowling", age=54) # instance of Author class 37 | db.save(rowling) 38 | 39 | assert rowling._get_insert_sql() == ( 40 | "INSERT INTO author (age, name) VALUES (?, ?);", 41 | [54, "J. K. Rowling"] 42 | ) 43 | assert rowling.id == 1 44 | 45 | man = Author(name="Man Harsh", age=20) 46 | db.save(man) 47 | assert man.id == 2 48 | 49 | vik = Author(name="Vik Star", age=43) 50 | db.save(vik) 51 | assert vik.id == 3 52 | 53 | jack = Author(name="Jack Sparrow", age=34) 54 | db.save(jack) 55 | assert jack.id == 4 56 | 57 | def test_query_all_authors(db, Author): 58 | db.create(Author) 59 | rowling = Author(name="J. K. Rowling", age=54) 60 | vik = Author(name="Vik Star", age=43) 61 | db.save(rowling) 62 | db.save(vik) 63 | 64 | authors = db.all(Author) 65 | 66 | assert Author._get_select_sql() == ( 67 | "SELECT id, age, name FROM author;", 68 | ["id", "age", "name"] 69 | ) 70 | assert len(authors) == 2 71 | assert type(authors[0]) == Author 72 | assert {a.age for a in authors} == {54, 43} 73 | assert {a.name for a in authors} == {"J. K. Rowling", "Vik Star"} 74 | 75 | def test_get_author(db, Author): 76 | db.create(Author) 77 | 78 | novel = Author(name="J. K. Rowling", age=54) 79 | db.save(novel) 80 | 81 | rowling_from_db = db.get(Author, id=1) 82 | 83 | assert Author._get_select_where_sql(id=1) == ( 84 | "SELECT id, age, name FROM author WHERE id = ?;", 85 | ["id", "age", "name"], 86 | [1] 87 | ) 88 | assert rowling_from_db.name == "J. K. Rowling" 89 | assert rowling_from_db.age == 54 90 | assert rowling_from_db.id == 1 91 | assert type(rowling_from_db) == Author 92 | 93 | def test_get_book(db, Author, Book): 94 | db.create(Author) 95 | db.create(Book) 96 | 97 | rowling = Author(name="J. K. Rowling", age=54) 98 | db.save(rowling) 99 | 100 | harry_potter = Book(title="Harry Potter", published=True, author=rowling) 101 | db.save(harry_potter) 102 | 103 | book_from_db = db.get(Book, 1) 104 | 105 | assert book_from_db.title == "Harry Potter" 106 | assert book_from_db.published == True 107 | assert book_from_db.author.name == "J. K. Rowling" 108 | assert book_from_db.author.age == 54 109 | assert book_from_db.author.id == 1 110 | assert type(book_from_db) == Book 111 | 112 | def test_query_all_books(db, Author, Book): 113 | db.create(Author) 114 | db.create(Book) 115 | 116 | rowling = Author(name="J. K. Rowling", age=54) 117 | db.save(rowling) 118 | 119 | harry_potter = Book(title="Harry Potter", published=True, author=rowling) 120 | db.save(harry_potter) 121 | 122 | books = db.all(Book) 123 | 124 | assert len(books) == 1 125 | assert books[0].title == "Harry Potter" 126 | assert books[0].published == True 127 | assert books[0].author.name == "J. K. Rowling" 128 | assert books[0].author.age == 54 129 | assert books[0].author.id == 1 130 | assert type(books[0]) == Book 131 | 132 | def test_update_author(db, Author): 133 | db.create(Author) 134 | 135 | rowling = Author(name="J. K. Rowling", age=54) 136 | db.save(rowling) 137 | 138 | rowling.name = "J. K. Rowling (Joanne)" 139 | rowling.age = 55 140 | db.update(rowling) 141 | 142 | rowling_from_db = db.get(Author, id = rowling.id) 143 | 144 | assert rowling_from_db.name == "J. K. Rowling (Joanne)" 145 | assert rowling_from_db.age == 55 146 | 147 | def test_delete_author(db, Author): 148 | db.create(Author) 149 | 150 | rowling = Author(name="J. K. Rowling", age=54) 151 | db.save(rowling) 152 | 153 | db.delete(Author, id=1) 154 | 155 | with pytest.raises(Exception): 156 | db.get(Author, 1) 157 | -------------------------------------------------------------------------------- /LumosWeb/api.py: -------------------------------------------------------------------------------- 1 | import select 2 | import socket 3 | from webob import Request 4 | from parse import parse 5 | import inspect 6 | from requests import Session as RequestsSession 7 | from wsgiadapter import WSGIAdapter as RequestsWSGIAdapter 8 | from wsgiref.simple_server import make_server 9 | import os 10 | from jinja2 import Environment, FileSystemLoader 11 | from whitenoise import WhiteNoise 12 | from .middleware import Middleware 13 | from .response import Response 14 | import markdown 15 | 16 | class API: 17 | def __init__(self, templates_dir="templates", static_dir="static"): 18 | self.routes = {} # dictionary of routes and handlers, path as keys and handlers as values 19 | 20 | self.templates_env = Environment( 21 | loader = FileSystemLoader(os.path.abspath(templates_dir)) 22 | ) 23 | 24 | self.exception_handler = None 25 | 26 | self.whitenoise = WhiteNoise(self.wsgi_app, root=static_dir) 27 | 28 | self.middleware = Middleware(self) 29 | 30 | self._server = None 31 | 32 | def __call__(self, environ, start_response): 33 | path_info = environ["PATH_INFO"] 34 | 35 | if path_info.startswith("/static"): 36 | environ["PATH_INFO"] = path_info[len("/static"):] 37 | return self.whitenoise(environ, start_response) 38 | 39 | return self.middleware(environ, start_response) 40 | 41 | def wsgi_app(self, environ, start_response): 42 | request = Request(environ) 43 | 44 | response = self.handle_request(request) 45 | 46 | return response(environ, start_response) 47 | 48 | def add_route(self, path, handler, allowed_methods=None): 49 | assert path not in self.routes, "You have already used this route, please choose another route :)" 50 | 51 | self.routes[path] = {"handler":handler, "allowed_methods": allowed_methods} # path as an argument. 52 | 53 | def route(self, path, allowed_methods=None): 54 | def wrapper(handler): 55 | self.add_route(path, handler, allowed_methods) 56 | return handler 57 | return wrapper 58 | 59 | def default_response(self, response): 60 | response.status_code = 404 61 | response.text = "Not found. :(" 62 | 63 | def find_handler(self, request_path): 64 | for path, handler in self.routes.items(): 65 | parse_result = parse(path, request_path) 66 | 67 | if parse_result is not None: 68 | return handler, parse_result.named 69 | return None, None 70 | 71 | def handle_request(self, request): 72 | response = Response() 73 | 74 | handler_data, kwargs = self.find_handler(request_path=request.path) 75 | try: 76 | if handler_data is not None: 77 | handler = handler_data["handler"] 78 | allowed_methods = handler_data["allowed_methods"] 79 | if inspect.isclass(handler): # To check if the handler is a class 80 | handler = getattr(handler(), request.method.lower(), None) # To get the method of the class, if handler() doesn't has the method attribute,getattr() returns None 81 | if handler is None: 82 | raise AttributeError("Method not allowed", request.method) 83 | else: 84 | if request.method.lower() not in allowed_methods: 85 | raise AttributeError("Method not allowed", request.method) 86 | handler(request, response, **kwargs) # **kwargs is used to unpack the dictionary 87 | else: 88 | self.default_response(response) 89 | except Exception as e: 90 | if self.exception_handler is None: 91 | raise e 92 | else: 93 | self.exception_handler(request, response, e) 94 | 95 | return response 96 | 97 | # To create a test client for the API 98 | def test_session(self, base_url="http://testserver"): 99 | session = RequestsSession() 100 | session.mount(prefix=base_url, adapter=RequestsWSGIAdapter(self)) 101 | return session 102 | 103 | def template(self, template_name, context=None): 104 | if context is None: 105 | context = {} 106 | 107 | template = self.templates_env.get_template(template_name) 108 | rendered_template = template.render(**context) 109 | 110 | # Check if the file ends with .md extension 111 | if template_name.endswith('.md'): 112 | # Convert the rendered template to HTML using Markdown 113 | converted_html = markdown.markdown(rendered_template, extensions=['fenced_code', 'codehilite', 'tables']) 114 | css_path = os.path.join(os.path.dirname(__file__), 'static/styles.css') 115 | with open(css_path, encoding="utf16") as css_file: 116 | css_content = css_file.read() 117 | rendered_template = f"{converted_html}" 118 | 119 | return rendered_template 120 | 121 | def add_exception_handler(self, exception_handler): 122 | self.exception_handler = exception_handler 123 | 124 | def add_middleware(self, middleware_cls): 125 | self.middleware.add(middleware_cls) 126 | 127 | def is_running(self): 128 | return self._server is not None and not self._server._BaseServer__is_shut_down.is_set() 129 | 130 | def run(self, host="localhost", port=8080, timeout=None): 131 | attempts = 0 132 | while True: 133 | try: 134 | # Check if the port is available 135 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 136 | sock.bind((host, port)) 137 | sock.close() 138 | break # Exit the loop if the port is available 139 | except OSError: 140 | print(f"Port {port} is not available, trying the next port") 141 | attempts += 1 142 | port += 1 143 | if attempts > 10: 144 | raise Exception("No ports available to run the API") 145 | 146 | server = make_server(host, port, self) 147 | self._server = server 148 | actual_port = server.server_port 149 | print(f"Starting Lumos server on {host}:{actual_port}") 150 | 151 | if timeout is None: 152 | server.serve_forever() 153 | else: 154 | while True: 155 | r, _, _ = select.select([server], [], [], timeout) 156 | if r: 157 | server.handle_request() 158 | else: 159 | break 160 | -------------------------------------------------------------------------------- /LumosWeb/orm.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import sqlite3 3 | from typing import Any 4 | 5 | class Database: 6 | def __init__(self, path): 7 | self.conn = sqlite3.Connection(path) 8 | 9 | @property 10 | def tables(self): 11 | SELECT_TABLES_SQL = "SELECT name FROM sqlite_master WHERE type='table';" 12 | return [x[0] for x in self.conn.execute(SELECT_TABLES_SQL).fetchall()] 13 | 14 | def create(self, table): 15 | self.conn.execute(table._get_create_sql()) 16 | 17 | def save(self, instance): 18 | sql, values = instance._get_insert_sql() 19 | cursor = self.conn.execute(sql, values) 20 | instance._data["id"] = cursor.lastrowid 21 | self.conn.commit() 22 | 23 | def all(self, table): 24 | sql, fields = table._get_select_sql() 25 | 26 | result = [] 27 | for row in self.conn.execute(sql).fetchall(): 28 | instance = table() 29 | for field, value in zip(fields, row): 30 | if field.endswith("_id"): 31 | field = field[:-3] 32 | fk = getattr(table, field) 33 | value = self.get(fk.table, value) 34 | setattr(instance, field, value) 35 | result.append(instance) 36 | 37 | return result 38 | 39 | def get(self, table, id): 40 | sql, fields, params = table._get_select_where_sql(id = id) 41 | 42 | row = self.conn.execute(sql, params).fetchone() 43 | if row is None: 44 | raise Exception(f"{table.__name__} instance with id {id} does not exist") 45 | 46 | instance = table() 47 | for field, value in zip(fields, row): 48 | if field.endswith("_id"): 49 | field = field[:-3] 50 | fk = getattr(table, field) 51 | value = self.get(fk.table, value) 52 | setattr(instance, field, value) 53 | 54 | return instance 55 | 56 | def update(self, instance): 57 | sql, values = instance._get_update_sql() 58 | self.conn.execute(sql, values) 59 | self.conn.commit() 60 | 61 | def delete(self, table, id): 62 | sql, params = table._get_delete_sql(id) 63 | self.conn.execute(sql, params) 64 | self.conn.commit() 65 | 66 | class Table: 67 | def __init__(self, **kwargs): 68 | self._data= { 69 | "id": None 70 | } 71 | 72 | for key, value in kwargs.items(): 73 | self._data[key] = value 74 | 75 | @classmethod 76 | def _get_create_sql(cls): 77 | CREATE_TABLE_SQL = "CREATE TABLE IF NOT EXISTS {name} ({fields});" 78 | fields = [ 79 | "id INTEGER PRIMARY KEY AUTOINCREMENT" 80 | ] 81 | 82 | for name, field in inspect.getmembers(cls): 83 | if isinstance(field, Column): 84 | fields.append(f"{name} {field.sql_type}") 85 | elif isinstance(field, ForeignKey): 86 | fields.append(f"{name}_id INTEGER") 87 | 88 | fields = ", ".join(fields) 89 | name = cls.__name__.lower() 90 | return CREATE_TABLE_SQL.format(name=name, fields=fields) 91 | 92 | def __getattribute__(self, key): 93 | _data = super().__getattribute__("_data") 94 | if key in _data: 95 | return _data[key] 96 | return super().__getattribute__(key) 97 | 98 | def _get_insert_sql(self): 99 | INSERT_SQL = "INSERT INTO {name} ({fields}) VALUES ({placeholders});" 100 | cls = self.__class__ 101 | fields = [] 102 | placeholders = [] 103 | values = [] 104 | 105 | for name, field in inspect.getmembers(cls): 106 | if isinstance(field, Column): 107 | fields.append(name) 108 | values.append(getattr(self, name)) 109 | placeholders.append("?") 110 | elif isinstance(field, ForeignKey): 111 | fields.append(name + "_id") 112 | values.append(getattr(self, name).id) 113 | placeholders.append("?") 114 | 115 | fields = ", ".join(fields) 116 | placeholders = ", ".join(placeholders) 117 | 118 | sql = INSERT_SQL.format(name=cls.__name__.lower(), fields=fields, placeholders=placeholders) 119 | 120 | return sql, values 121 | 122 | @classmethod 123 | def _get_select_sql(cls): 124 | SELECT_ALL_SQL = "SELECT {fields} FROM {name};" 125 | 126 | fields = ["id"] 127 | for name, field in inspect.getmembers(cls): 128 | if isinstance(field, Column): 129 | fields.append(name) 130 | if isinstance(field, ForeignKey): 131 | fields.append(name + "_id") 132 | 133 | sql = SELECT_ALL_SQL.format(name=cls.__name__.lower(), fields=", ".join(fields)) 134 | 135 | return sql, fields 136 | 137 | @classmethod 138 | def _get_select_where_sql(cls, id): 139 | SELECT_WHERE_SQL = "SELECT {fields} FROM {name} WHERE id = ?;" 140 | fields = ["id"] 141 | for name, field in inspect.getmembers(cls): 142 | if isinstance(field, Column): 143 | fields.append(name) 144 | if isinstance(field, ForeignKey): 145 | fields.append(name + "_id") 146 | 147 | sql = SELECT_WHERE_SQL.format(name=cls.__name__.lower(), fields=", ".join(fields)) 148 | params = [id] 149 | 150 | return sql, fields, params 151 | 152 | def __setattr__(self, key, value): 153 | super().__setattr__(key, value) 154 | if key in self._data: 155 | self._data[key] = value 156 | 157 | def _get_update_sql(self): 158 | UPDATE_SQL = 'UPDATE {name} SET {fields} WHERE id = ?' 159 | cls = self.__class__ 160 | fields = [] 161 | values = [] 162 | 163 | for name, field in inspect.getmembers(cls): 164 | if isinstance(field, Column): 165 | fields.append(name) 166 | values.append(getattr(self, name)) 167 | elif isinstance(field, ForeignKey): 168 | fields.append(name + "_id") 169 | values.append(getattr(self, name).id) 170 | 171 | values.append(getattr(self, "id")) 172 | 173 | sql = UPDATE_SQL.format( 174 | name=cls.__name__.lower(), 175 | fields=", ".join([f"{field} = ?" for field in fields]) 176 | ) 177 | 178 | return sql, values 179 | 180 | @classmethod 181 | def _get_delete_sql(cls, id): 182 | DELETE_SQL = "DELETE FROM {name} WHERE id = ?" 183 | sql = DELETE_SQL.format(name=cls.__name__.lower()) 184 | 185 | return sql, [id] 186 | 187 | 188 | class Column: 189 | def __init__(self, column_type): 190 | self.type = column_type 191 | 192 | @property 193 | def sql_type(self): 194 | SQLITE_TYPE_MAP = { 195 | int: "INTEGER", 196 | float: "REAL", 197 | str: "TEXT", 198 | bytes: "BLOB", 199 | bool: "INTEGER", # 0 or 1 200 | } 201 | return SQLITE_TYPE_MAP[self.type] 202 | 203 | class ForeignKey: 204 | def __init__(self, table): 205 | self.table = table -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LumosWeb ![PyPI](https://img.shields.io/pypi/v/LumosWeb.svg) 2 | 3 | - To ensure compatibility and access the latest features and improvements, it is highly recommended to use version 1.0.0 or higher of the package. 4 | - LumosWeb is web framework written in python 5 | - It's a WSGI framework and can be used with any WSGI application server such as Gunicorn. 6 | - [PyPI Release](https://pypi.org/project/LumosWeb/) 7 | - [Sample App](https://github.com/Sddilora/LumosWeb-SampleApp) 8 | 9 | 10 | 11 | ## Installation 12 | ```shell 13 | pip install LumosWeb== 14 | e.g. pip install LumosWeb==1.0.0 15 | ``` 16 | 17 | ## Getting Started 18 | 19 | ## Basic usage 20 | 21 | ### Define App 22 | 23 | ```python 24 | from LumosWeb.api import API() 25 | app = API() # We created our api instance 26 | ``` 27 | 28 | ```python 29 | @app.route("/home", allowed_methods=["get", "post", "put", "delete"]) 30 | def home(request, response): 31 | if request.method == "get": 32 | response.text = "Hello from the HOME page" 33 | else: 34 | raise AttributeError("Method not allowed.") 35 | 36 | # Parameterized routes 37 | @app.route("/book/{title}/page/{page:d}", allowed_methods=["get", "post"]) 38 | def book(request, response, title, page): 39 | response.text = f"You are reading the Book: {title}, and you were on Page: {page}" 40 | 41 | ## Adding a route without a decorator 42 | def handler(req, resp): 43 | resp.text = "We don't have to use decorators!" 44 | 45 | app.add_route("/sample", handler, allowed_methods=["get", "post"]) 46 | 47 | 48 | ``` 49 | ### Run Server 50 | Navigate to the directory in the Terminal where the file of your API instance is located 51 | > Lumosweb --app run 52 | 53 | And lights are on! 54 | 55 | ### Unit Test 56 | 57 | The recommended way of writing unit tests is with [pytest](https://docs.pytest.org/en/latest/). There are two built in fixtures 58 | that you may want to use when writing unit tests with LumosWeb. The first one is `app` which is an instance of the main `API` class: 59 | ```python 60 | def test_basic_route_adding(api): 61 | @api.route("/home", allowed_methods=["get", "post"]) 62 | def home(req, resp): 63 | resp.text = "Lumos is on!" 64 | with pytest.raises(AssertionError): 65 | @api.route("/home", allowed_methods=["get", "post"]) 66 | def home2(req, resp): 67 | resp.text = "Lumos is off!" 68 | ``` 69 | The other one is `client` that you can use to send HTTP requests to your handlers. It is based on the famous [requests](https://requests.readthedocs.io/) and it should feel very familiar: 70 | ```python 71 | def test_lumos_test_client_can_send_requests(api, client): 72 | RESPONSE_TEXT = "Yes it can :)!" 73 | 74 | @api.route("/lumos", allowed_methods=["get", "post"]) 75 | def lumos(req, resp): 76 | resp.text = RESPONSE_TEXT 77 | 78 | assert client.get("http://testserver/lumos").text == RESPONSE_TEXT 79 | 80 | ``` 81 | 82 | ## Templates 83 | The default folder for templates is `templates`. You can change it when initializing the main `API()` class: 84 | ```python 85 | app = API(templates_dir="templates_dir_name") 86 | ``` 87 | Then you can use HTML or Markdown files in that folder like so in a handler: 88 | 89 | ```python 90 | @app.route("/show/template") 91 | def handler_with_template(req, resp): 92 | resp.html = app.template( 93 | "example.html", context={"title": "Awesome Framework", "body": "welcome to the future!"}) 94 | 95 | @app.route("/md-files", allowed_methods=["get"]) 96 | def index(req, resp): 97 | resp.html = app.template("index.md") 98 | ``` 99 | 100 | ## Static Files 101 | 102 | Just like templates, the default folder for static files is `static` and you can override it: 103 | ```python 104 | app = API(static_dir="static_dir_name") 105 | ``` 106 | Then you can use the files inside this folder in HTML files: 107 | ```html 108 | 109 | 110 | 111 | 112 | 113 | {{title}} 114 | 115 | 116 | 117 | 118 | 119 |

{{body}}

120 |

This is a paragraph

121 | 122 | 123 | ``` 124 | 125 | ### Middleware 126 | You can create custom middleware classes by inheriting from the `LumosWeb.middleware.Middleware` class and overriding its two methods 127 | that are called before and after each request: 128 | 129 | ```python 130 | from LumosWeb.api import API 131 | from LumosWeb.middleware import Middleware 132 | 133 | app = API() 134 | 135 | class SimpleCustomMiddleware(Middleware): 136 | def process_request(self, req): 137 | print("Before dispatch", req.url) 138 | 139 | def process_response(self, req, res): 140 | print("After dispatch", req.url) 141 | 142 | 143 | app.add_middleware(SimpleCustomMiddleware) 144 | ``` 145 | 146 | ### Database 147 | You can create custom middleware classes by inheriting from the `LumosWeb.orm.Database` class 148 | First create models file and create a class for each table in the database 149 | 150 | ```python 151 | # models.py 152 | 153 | from LumosWeb.orm import Table, Column 154 | 155 | class Book(Table): 156 | author = Column(str) 157 | name = Column(str) 158 | ``` 159 | Then create a storage file and import the models 160 | 161 | ```python 162 | # storage.py 163 | 164 | from models import Book 165 | 166 | class BookStorage: 167 | _id = 0 168 | 169 | def __init__(self): 170 | self._books = [] 171 | 172 | def all(self): 173 | return [book._asdict() for book in self._books] 174 | 175 | def get(self, id: int): 176 | for book in self._books: 177 | if book.id == id: 178 | return book 179 | 180 | return None 181 | 182 | def create(self, **kwargs): 183 | self._id += 1 184 | kwargs["id"] = self._id 185 | book = Book(**kwargs) 186 | self._books.append(book) 187 | return book 188 | 189 | def delete(self, id): 190 | for ind, book in enumerate(self._books): 191 | if book.id == id: 192 | del self._books[ind] 193 | ``` 194 | Now you can use them 195 | 196 | ```python 197 | # app.py 198 | 199 | from LumosWeb.orm import Database 200 | 201 | db = Database("./lumos.db") # lumos.db is the name of the database file 202 | # which will be created in the current directory (if it doesn't exist already) 203 | db.create(Book) 204 | 205 | @app.route("/", allowed_methods=["get"]) 206 | def index(req, resp): 207 | books = db.all(Book) 208 | resp.html = app.template("index.html", context={"books": books}) 209 | 210 | @app.route("/books", allowed_methods=["post"]) 211 | def create_book(req, resp): 212 | book = Book(**req.POST) # Creates a Book instance with the given data in the request. 213 | db.save(book) 214 | 215 | resp.status_code = 201 # Created 216 | resp.json = {"name": book.name, "author": book.author} 217 | 218 | @app.route("/books/{id:d}", allowed_methods=["delete"]) 219 | def delete_book(req, resp, id): 220 | db.delete(Book, id=id) 221 | resp.status_code = 204 # No content (resource has successfully been deleted.) 222 | 223 | ``` 224 | -------------------------------------------------------------------------------- /test_lumos.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import pytest 3 | 4 | from LumosWeb.api import API 5 | from LumosWeb.middleware import Middleware 6 | 7 | FILE_DIR ="css" 8 | FILE_NAME = "main.css" 9 | FILE_CONTENTS = "body {background-color: #d0e4fe}" 10 | 11 | 12 | # helpers 13 | # A helper method is created that creates a static file under the given folder. 14 | def _create_static(static_dir): 15 | asset = static_dir.mkdir(FILE_DIR).join(FILE_NAME) 16 | asset.write(FILE_CONTENTS) 17 | 18 | return asset 19 | 20 | # tests 21 | 22 | def test_basic_route_adding(api): 23 | @api.route("/home", allowed_methods=["get", "post"]) 24 | def home(req, resp): 25 | resp.text = "Lumos is on!" 26 | with pytest.raises(AssertionError): 27 | @api.route("/home", allowed_methods=["get", "post"]) 28 | def home2(req, resp): 29 | resp.text = "Lumos is off!" 30 | 31 | def test_lumos_test_client_can_send_requests(api, client): 32 | RESPONSE_TEXT = "Yes it can :)!" 33 | 34 | @api.route("/lumos", allowed_methods=["get", "post"]) 35 | def lumos(req, resp): 36 | resp.text = RESPONSE_TEXT 37 | 38 | assert client.get("http://testserver/lumos").text == RESPONSE_TEXT 39 | 40 | def test_parametrized_route(api, client): 41 | @api.route("/{name}", allowed_methods=["get"]) 42 | def hello(req, resp, name): 43 | resp.text = f"Hey {name}" 44 | 45 | assert client.get("http://testserver/sdd").text == "Hey sdd" 46 | assert client.get("http://testserver/123").text == "Hey 123" 47 | 48 | def test_params_are_passed_correctly(api, client): 49 | @api.route("/sum/{num_1:d}/{num_2:d}", allowed_methods=["get", "post"]) 50 | def sum(req, resp, num_1, num_2): 51 | resp.text = f"{num_1} + {num_2} = {num_1 + num_2}" 52 | 53 | assert client.get("http://testserver/sum/12/13").text == "12 + 13 = 25" 54 | assert client.get("http://testserver/sum/12/13/14").status_code == 404 55 | assert client.get("http://testserver/sum/12/hello").status_code == 404 56 | 57 | def test_default_404_response(client): 58 | response = client.get("http://testserver/doesnotexist") 59 | assert response.status_code == 404 60 | assert response.text == "Not found. :(" 61 | 62 | def test_class_based_handler_get(api, client): 63 | RESPONSE_TEXT = "This is a GET request" 64 | 65 | @api.route("/book", allowed_methods=["get", "post"]) 66 | class BookResource: 67 | def get(self, req, resp): 68 | resp.text = RESPONSE_TEXT 69 | 70 | assert client.get("http://testserver/book").text == RESPONSE_TEXT 71 | 72 | def test_class_based_handler_post(api, client): 73 | RESPONSE_TEXT = "This is a POST request" 74 | 75 | @api.route("/book", allowed_methods=["get", "post"]) 76 | class BookResource: 77 | def post(self, req, resp): 78 | resp.text = RESPONSE_TEXT 79 | 80 | assert client.post("http://testserver/book").text == RESPONSE_TEXT 81 | 82 | def test_class_based_handler_not_allowed_method(api, client): 83 | @api.route("/book", allowed_methods=["get", "post"]) 84 | class BookResource: 85 | def post(self, req, resp): 86 | resp.text = "Lumos!" 87 | 88 | with pytest.raises(AttributeError): 89 | client.get("http://testserver/book") 90 | 91 | def test_alternative_route(api, client): 92 | RESPONSE_TEXT = "Alternative way to add a route" 93 | 94 | def home(req, resp): 95 | resp.text = RESPONSE_TEXT 96 | 97 | api.add_route("/alternative", home, allowed_methods=["get", "post"]) 98 | 99 | assert client.get("http://testserver/alternative").text == RESPONSE_TEXT 100 | 101 | def test_template(api, client): 102 | @api.route("/html", allowed_methods=["get", "post"]) 103 | def html_handler(req, resp): 104 | resp.body = api.template( 105 | "index.html", context={"title": "Some Title", "name": "Some Name"} 106 | ).encode() 107 | 108 | response = client.get("http://testserver/html") 109 | assert "text/html" in response.headers["Content-Type"] 110 | assert "Some Title" in response.text 111 | assert "Some Name" in response.text 112 | 113 | def test_custom_exception_handler(api, client): 114 | def on_exception(req, resp, exc): 115 | resp.text = "AttributeErrorHappened" 116 | 117 | api.add_exception_handler(on_exception) 118 | 119 | @api.route("/", allowed_methods=["get"]) 120 | def index(req, resp): 121 | raise AttributeError() 122 | 123 | response = client.get("http://testserver/") 124 | assert response.text == "AttributeErrorHappened" 125 | 126 | # This one tests if a 404 (Not Found) response is returned if a request is sent for a nonexistent static file. 127 | def test_404_is_returned_for_nonexistent_static_file(client): 128 | assert client.get(f"http://testserver/static/main.css)").status_code == 404 129 | 130 | def test_assets_are_served(tmpdir_factory): 131 | static_dir = tmpdir_factory.mktemp("static") 132 | _create_static(static_dir) 133 | 134 | api = API(static_dir=str(static_dir)) 135 | client = api.test_session() 136 | 137 | response = client.get(f"http://testserver/static/{FILE_DIR}/{FILE_NAME}") 138 | assert response.status_code == 200 139 | assert response.text == FILE_CONTENTS 140 | 141 | def test_middleware_methods_are_called(api, client): 142 | process_request_called = False 143 | process_response_called = False 144 | 145 | class CalledMiddleware(Middleware): 146 | def __init__(self, app): 147 | super().__init__(app) 148 | 149 | def process_request(self, req): 150 | nonlocal process_request_called 151 | process_request_called = True 152 | 153 | def process_response(self, req, resp): 154 | nonlocal process_response_called 155 | process_response_called = True 156 | 157 | api.add_middleware(CalledMiddleware) 158 | 159 | @api.route("/", allowed_methods=["get"]) 160 | def index(req, resp): 161 | resp.text = "Hello Middleware!" 162 | 163 | client.get("http://testserver/") 164 | 165 | assert process_request_called is True 166 | assert process_response_called is True 167 | 168 | def test_allowed_methods_for_function_based_handlers(api, client): 169 | @api.route("/home", allowed_methods=["post"]) 170 | def home(req, resp): 171 | resp.text = "Hello" 172 | 173 | with pytest.raises(AttributeError): 174 | client.get("http://testserver/home") 175 | 176 | assert client.post("http://testserver/home").text == "Hello" 177 | 178 | def test_json_response_helper(api, client): 179 | @api.route("/json", allowed_methods=["get", "post"]) 180 | def json_handler(req, resp): 181 | resp.json = {"name": "Lumos"} 182 | 183 | response = client.get("http://testserver/json") 184 | json_body = response.json() 185 | assert response.headers["Content-Type"] == "application/json" 186 | assert json_body["name"] == "Lumos" 187 | 188 | def test_html_response_helper(api, client): 189 | @api.route("/html", allowed_methods=["get", "post"]) 190 | def html_handler(req, resp): 191 | resp.html = api.template( 192 | "index.html", context={"title": "Some Title", "name": "Some Name"} 193 | ) 194 | 195 | response = client.get("http://testserver/html") 196 | assert "text/html" in response.headers["Content-Type"] 197 | assert "Some Title" in response.text 198 | assert "Some Name" in response.text 199 | 200 | def test_text_response_helper(api, client): 201 | response_text = "Plain text response from LumosWeb" 202 | 203 | @api.route("/text", allowed_methods=["get", "post"]) 204 | def text_handler(req, resp): 205 | resp.text = response_text 206 | 207 | response = client.get("http://testserver/text") 208 | 209 | assert "text/plain" in response.headers["Content-Type"] 210 | assert response.text == response_text 211 | 212 | def test_manually_setting_body(api, client): 213 | @api.route("/body", allowed_methods=["get", "post"]) 214 | def text_handler(req, resp): 215 | resp.body = b"Byte body" 216 | resp.content_type = "text/plain" 217 | 218 | response = client.get("http://testserver/body") 219 | 220 | assert "text/plain" in response.headers["Content-Type"] 221 | assert response.text == "Byte body" 222 | 223 | def test_run_success(api): 224 | host = "localhost" 225 | port = 8080 226 | 227 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: 228 | sock.bind((host, port)) 229 | 230 | try: 231 | api.run(host=host, port=port, timeout=1) # Set a short timeout for testing purposes 232 | assert api.is_running() 233 | finally: 234 | sock.close() 235 | 236 | def test_run_alternative_port(api): 237 | host = "localhost" 238 | port = 8080 239 | 240 | # Create a socket and bind it to port 8080 to simulate a busy port 241 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: 242 | sock.bind((host, port)) 243 | 244 | try: 245 | api.run(host=host, port=port, timeout=1) # Set a short timeout for testing purposes 246 | assert api.is_running() # Check if the API is running (on another port, api.run changes the port if default port is not available) even though the default port is busy 247 | except Exception as exc: 248 | assert str(exc) == "No ports available to run the API" 249 | finally: 250 | sock.close() 251 | 252 | def test_run_exception_other_error(api): 253 | host = "localhost" 254 | port = 8080 255 | 256 | try: 257 | with pytest.raises(Exception): 258 | # Simulate a different exception by passing an invalid value for `host` 259 | api.run(host=None, port=port, timeout=1) # Set a short timeout for testing purposes 260 | finally: 261 | assert not api.is_running() 262 | 263 | --------------------------------------------------------------------------------