Skip to content

Commit 3886222

Browse files
committed
feat: add django-like URLs and views
1 parent ce47671 commit 3886222

File tree

12 files changed

+268
-31
lines changed

12 files changed

+268
-31
lines changed

.gitignore

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ ipython_config.py
9999
# This is especially recommended for binary packages to ensure reproducibility, and is more
100100
# commonly ignored for libraries.
101101
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102-
#poetry.lock
102+
poetry.lock
103103

104104
# pdm
105105
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
@@ -160,3 +160,8 @@ cython_debug/
160160
# and can be added to the global gitignore or merged into this file. For a more nuclear
161161
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
162162
#.idea/
163+
164+
*.html
165+
test*.py
166+
*.db
167+
*.log

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,27 @@ Once installed, you can start using the library in your Python projects. Check o
5353

5454
<p align="right">(<a href="#readme-top">back to top</a>)</p>
5555

56+
### Advanced app with flask-like and django-like routes
57+
Index page and book
58+
59+
```python
60+
from pyechonext.app import ApplicationType, EchoNext
61+
from pyechonext.views import View
62+
from pyechonext.urls import url_patterns
63+
64+
65+
echonext = EchoNext(url_patterns, __name__, application_type=ApplicationType.HTML)
66+
67+
68+
@echonext.route_page("/book")
69+
class BooksResource(View):
70+
def get(self, request, response, **kwargs):
71+
return f"Books Page: {request.query_params}"
72+
73+
def post(self, request, response, **kwargs):
74+
return "Endpoint to create a book"
75+
```
76+
5677
### Simple app with database
5778
In this example we are using SQLSymphony ORM (our other project, a fast and simple ORM for python)
5879

examples/advanced_app.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from pyechonext.app import ApplicationType, EchoNext
2+
from pyechonext.views import View
3+
from pyechonext.urls import url_patterns
4+
5+
6+
echonext = EchoNext(url_patterns, __name__, application_type=ApplicationType.HTML)
7+
8+
9+
@echonext.route_page("/book")
10+
class BooksResource(View):
11+
def get(self, request, response, **kwargs):
12+
return f"Books Page: {request.query_params}"
13+
14+
def post(self, request, response, **kwargs):
15+
return "Endpoint to create a book"

examples/simple_app.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
from sqlsymphony_orm.datatypes.fields import IntegerField, RealField, TextField
33
from sqlsymphony_orm.models.session_models import SessionModel
44
from sqlsymphony_orm.models.session_models import SQLiteSession
5-
from sqlsymphony_orm.queries import QueryBuilder
65

76

87
echonext = EchoNext(__name__, application_type=ApplicationType.HTML)
@@ -22,7 +21,7 @@ def __repr__(self):
2221

2322
@echonext.route_page("/")
2423
def home(request, response):
25-
user = User(name='John', cash=100.0)
24+
user = User(name="John", cash=100.0)
2625
session.add(user)
2726
session.commit()
2827
response.body = "Hello from the HOME page"
@@ -31,5 +30,5 @@ def home(request, response):
3130
@echonext.route_page("/users")
3231
def about(request, response):
3332
users = session.get_all_by_model(User)
34-
33+
3534
response.body = f"Users: {[f'{user.name}: {user.cash}$' for user in users]}"

pyechonext/app.py

Lines changed: 84 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,35 @@
1+
import re
2+
import inspect
13
from enum import Enum
2-
from typing import Iterable, Callable
4+
from typing import Iterable, Callable, List, Type
35
from socks import method
6+
from parse import parse
7+
from pyechonext.urls import URL
8+
from pyechonext.views import View
49
from pyechonext.request import Request
510
from pyechonext.response import Response
11+
from pyechonext.utils.exceptions import RoutePathExistsError, MethodNotAllow
12+
from pyechonext.utils import _prepare_url
613

714

815
class ApplicationType(Enum):
9-
JSON = 'application/json'
10-
HTML = 'text/html'
16+
JSON = "application/json"
17+
HTML = "text/html"
1118

1219

1320
class EchoNext:
1421
"""
1522
This class describes an EchoNext WSGI Application.
1623
"""
1724

18-
def __init__(self, app_name: str, application_type: ApplicationType=ApplicationType.JSON):
25+
__slots__ = ("app_name", "application_type", "urls", "routes")
26+
27+
def __init__(
28+
self,
29+
urls: List[URL],
30+
app_name: str,
31+
application_type: ApplicationType = ApplicationType.JSON,
32+
):
1933
"""
2034
Constructs a new instance.
2135
@@ -25,6 +39,35 @@ def __init__(self, app_name: str, application_type: ApplicationType=ApplicationT
2539
self.app_name = app_name
2640
self.application_type = application_type
2741
self.routes = {}
42+
self.urls = urls
43+
44+
def _find_view(self, raw_url: str) -> Type[View]:
45+
url = _prepare_url(raw_url)
46+
47+
for path in self.urls:
48+
match = re.match(path.url, url)
49+
50+
if match is not None:
51+
return path
52+
53+
return None
54+
55+
# raise URLNotFound(f'URL "{raw_url}" not found.')
56+
57+
def _check_request_method(self, view: View, request: Request):
58+
if not hasattr(view, request.method.lower()):
59+
raise MethodNotAllow(f"Method not allow: {request.method}")
60+
61+
def _get_view(self, request: Request) -> View:
62+
url = request.path
63+
64+
return self._find_view(url)()
65+
66+
def _get_request(self, environ: dict) -> Request:
67+
return Request(environ)
68+
69+
def _get_response(self) -> Response:
70+
return Response(content_type=self.application_type.value)
2871

2972
def route_page(self, page_path: str) -> Callable:
3073
"""
@@ -36,6 +79,8 @@ def route_page(self, page_path: str) -> Callable:
3679
:returns: wrapper handler
3780
:rtype: Callable
3881
"""
82+
if page_path in self.routes:
83+
raise RoutePathExistsError("Such route already exists.")
3984

4085
def wrapper(handler):
4186
self.routes[page_path] = handler
@@ -47,12 +92,36 @@ def default_response(self, response: Response) -> None:
4792
"""
4893
Get default response (404)
4994
50-
:param response: The response
51-
:type response: Response
95+
:param response: The response
96+
:type response: Response
5297
"""
5398
response.status_code = "404"
5499
response.body = "Page Not Found Error."
55100

101+
def find_handler(self, request_path: str) -> Callable:
102+
"""
103+
Finds a handler.
104+
105+
:param request_path: The request path
106+
:type request_path: str
107+
108+
:returns: handler function
109+
:rtype: Callable
110+
"""
111+
for path, handler in self.routes.items():
112+
parse_result = parse(path, request_path)
113+
if parse_result is not None:
114+
return handler, parse_result.named
115+
116+
view = self._find_view(request_path)
117+
118+
if view is not None:
119+
parse_result = parse(_prepare_url(view.url), request_path)
120+
if parse_result is not None:
121+
return view.view, parse_result.named
122+
123+
return None, None
124+
56125
def handle_response(self, request: Request) -> Response:
57126
"""
58127
Handle response from request
@@ -63,31 +132,22 @@ def handle_response(self, request: Request) -> Response:
63132
:returns: Response callable object
64133
:rtype: Response
65134
"""
66-
response = Response(content_type=self.application_type.value)
135+
response = self._get_response()
67136

68-
handler = self.find_handler(request_path=request.path)
137+
handler, kwargs = self.find_handler(request_path=request.path)
69138

70139
if handler is not None:
71-
handler(request, response)
140+
if inspect.isclass(handler):
141+
handler = getattr(handler(), request.method.lower(), None)
142+
if handler is None:
143+
raise MethodNotAllow(f"Method not allowed: {request.method}")
144+
145+
response.body = handler(request, response, **kwargs)
72146
else:
73147
self.default_response(response)
74148

75149
return response
76150

77-
def find_handler(self, request_path: str) -> Callable:
78-
"""
79-
Finds a handler.
80-
81-
:param request_path: The request path
82-
:type request_path: str
83-
84-
:returns: handler function
85-
:rtype: Callable
86-
"""
87-
for path, handler in self.routes.items():
88-
if path == request_path:
89-
return handler
90-
91151
def __call__(self, environ: dict, start_response: method) -> Iterable:
92152
"""
93153
Makes the application object callable
@@ -100,7 +160,7 @@ def __call__(self, environ: dict, start_response: method) -> Iterable:
100160
:returns: response body
101161
:rtype: Iterable
102162
"""
103-
request = Request(environ)
163+
request = self._get_request(environ)
104164
response = self.handle_response(request)
105165

106166
return response(environ, start_response)

pyechonext/request.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
from urllib.parse import parse_qs
23
from loguru import logger
34

45

@@ -17,12 +18,16 @@ def __init__(self, environ: dict):
1718
self.environ = environ
1819
self.method = self.environ["REQUEST_METHOD"]
1920
self.path = self.environ["PATH_INFO"]
20-
self.query_params = self.environ["QUERY_STRING"]
21+
self.query_params = self.build_get_params_dict(self.environ["QUERY_STRING"])
2122
self.body = self.environ["wsgi.input"].read().decode()
2223
self.user_agent = self.environ["HTTP_USER_AGENT"]
2324

2425
logger.debug(f"New request created: {self.method} {self.path}")
2526

27+
def build_get_params_dict(self, raw_params: str):
28+
self.GET = parse_qs(raw_params)
29+
return self.GET
30+
2631
@property
2732
def json(self) -> dict:
2833
"""

pyechonext/response.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@ def __call__(self, environ: dict, start_response: method) -> Iterable:
9999
self.headers.append(("User-Agent", environ["HTTP_USER_AGENT"]))
100100
self.headers.append(("Content-Length", str(len(self.body))))
101101

102-
logger.debug(f"[{self.status_code}] Run response: {self.content_type}")
102+
logger.debug(
103+
f"[{environ['REQUEST_METHOD']} {self.status_code}] Run response: {self.content_type}"
104+
)
103105
start_response(status=self.status_code, headers=self.headers)
104106

105107
return iter([self.body])

pyechonext/urls.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from dataclasses import dataclass
2+
from typing import Type
3+
from pyechonext.views import View, IndexView
4+
5+
6+
@dataclass
7+
class URL:
8+
url: str
9+
view: Type[View]
10+
11+
12+
url_patterns = [URL(url="", view=IndexView)]

pyechonext/utils/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
def _prepare_url(url: str) -> str:
2+
try:
3+
if url[-1] == "/":
4+
return url[:-1]
5+
except IndexError:
6+
return "/"
7+
8+
return url

pyechonext/utils/exceptions.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,40 @@ def __str__(self):
3535
:rtype: str
3636
"""
3737
logger.error(f"{self.__class__.__name__}: {self.get_explanation()}")
38-
return f"SQLSymphonyException has been raised. {self.get_explanation()}"
38+
return f"pyEchoNextException has been raised. {self.get_explanation()}"
39+
40+
41+
class RoutePathExistsError(pyEchoNextException):
42+
def __str__(self):
43+
"""
44+
Returns a string representation of the object.
45+
46+
:returns: String representation of the object.
47+
:rtype: str
48+
"""
49+
logger.error(f"{self.__class__.__name__}: {self.get_explanation()}")
50+
return f"RoutePathExistsError has been raised. {self.get_explanation()}"
51+
52+
53+
class URLNotFound(pyEchoNextException):
54+
def __str__(self):
55+
"""
56+
Returns a string representation of the object.
57+
58+
:returns: String representation of the object.
59+
:rtype: str
60+
"""
61+
logger.error(f"{self.__class__.__name__}: {self.get_explanation()}")
62+
return f"URLNotFound has been raised. {self.get_explanation()}"
63+
64+
65+
class MethodNotAllow(pyEchoNextException):
66+
def __str__(self):
67+
"""
68+
Returns a string representation of the object.
69+
70+
:returns: String representation of the object.
71+
:rtype: str
72+
"""
73+
logger.error(f"{self.__class__.__name__}: {self.get_explanation()}")
74+
return f"MethodNotAllow has been raised. {self.get_explanation()}"

0 commit comments

Comments
 (0)