panther 4.3.7__py3-none-any.whl → 5.0.0b1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- panther/__init__.py +1 -1
- panther/_load_configs.py +78 -64
- panther/_utils.py +1 -1
- panther/app.py +126 -60
- panther/authentications.py +26 -9
- panther/base_request.py +27 -2
- panther/base_websocket.py +26 -27
- panther/cli/create_command.py +1 -0
- panther/cli/main.py +19 -27
- panther/cli/monitor_command.py +8 -4
- panther/cli/template.py +11 -6
- panther/cli/utils.py +3 -2
- panther/configs.py +7 -9
- panther/db/cursor.py +23 -7
- panther/db/models.py +26 -19
- panther/db/queries/base_queries.py +1 -1
- panther/db/queries/mongodb_queries.py +172 -10
- panther/db/queries/pantherdb_queries.py +5 -5
- panther/db/queries/queries.py +1 -1
- panther/events.py +10 -4
- panther/exceptions.py +24 -2
- panther/generics.py +2 -2
- panther/main.py +80 -117
- panther/middlewares/__init__.py +1 -1
- panther/middlewares/base.py +15 -19
- panther/middlewares/monitoring.py +42 -0
- panther/openapi/__init__.py +1 -0
- panther/openapi/templates/openapi.html +27 -0
- panther/openapi/urls.py +5 -0
- panther/openapi/utils.py +167 -0
- panther/openapi/views.py +101 -0
- panther/pagination.py +1 -1
- panther/panel/middlewares.py +10 -0
- panther/panel/templates/base.html +14 -0
- panther/panel/templates/create.html +21 -0
- panther/panel/templates/create.js +1270 -0
- panther/panel/templates/detail.html +55 -0
- panther/panel/templates/home.html +9 -0
- panther/panel/templates/home.js +30 -0
- panther/panel/templates/login.html +47 -0
- panther/panel/templates/sidebar.html +13 -0
- panther/panel/templates/table.html +73 -0
- panther/panel/templates/table.js +339 -0
- panther/panel/urls.py +10 -5
- panther/panel/utils.py +98 -0
- panther/panel/views.py +143 -0
- panther/request.py +3 -0
- panther/response.py +91 -53
- panther/routings.py +7 -2
- panther/serializer.py +1 -1
- panther/utils.py +34 -26
- panther/websocket.py +3 -0
- {panther-4.3.7.dist-info → panther-5.0.0b1.dist-info}/METADATA +19 -17
- panther-5.0.0b1.dist-info/RECORD +75 -0
- {panther-4.3.7.dist-info → panther-5.0.0b1.dist-info}/WHEEL +1 -1
- panther-4.3.7.dist-info/RECORD +0 -57
- {panther-4.3.7.dist-info → panther-5.0.0b1.dist-info}/entry_points.txt +0 -0
- {panther-4.3.7.dist-info → panther-5.0.0b1.dist-info}/licenses/LICENSE +0 -0
- {panther-4.3.7.dist-info → panther-5.0.0b1.dist-info}/top_level.txt +0 -0
panther/middlewares/base.py
CHANGED
@@ -1,30 +1,26 @@
|
|
1
|
+
import typing
|
2
|
+
|
3
|
+
from panther.base_websocket import Websocket
|
1
4
|
from panther.request import Request
|
2
5
|
from panther.response import Response
|
3
6
|
from panther.websocket import GenericWebsocket
|
4
7
|
|
5
8
|
|
6
|
-
class
|
7
|
-
"""Used in both http & ws requests"""
|
8
|
-
async def before(self, request: Request | GenericWebsocket):
|
9
|
-
raise NotImplementedError
|
10
|
-
|
11
|
-
async def after(self, response: Response | GenericWebsocket):
|
12
|
-
raise NotImplementedError
|
13
|
-
|
14
|
-
|
15
|
-
class HTTPMiddleware(BaseMiddleware):
|
9
|
+
class HTTPMiddleware:
|
16
10
|
"""Used only in http requests"""
|
17
|
-
async def before(self, request: Request):
|
18
|
-
return request
|
19
11
|
|
20
|
-
|
21
|
-
|
12
|
+
def __init__(self, dispatch: typing.Callable):
|
13
|
+
self.dispatch = dispatch
|
14
|
+
|
15
|
+
async def __call__(self, request: Request) -> Response:
|
16
|
+
return await self.dispatch(request=request)
|
22
17
|
|
23
18
|
|
24
|
-
class WebsocketMiddleware
|
19
|
+
class WebsocketMiddleware:
|
25
20
|
"""Used only in ws requests"""
|
26
|
-
async def before(self, request: GenericWebsocket):
|
27
|
-
return request
|
28
21
|
|
29
|
-
|
30
|
-
|
22
|
+
def __init__(self, dispatch: typing.Callable):
|
23
|
+
self.dispatch = dispatch
|
24
|
+
|
25
|
+
async def __call__(self, connection: Websocket) -> GenericWebsocket:
|
26
|
+
return await self.dispatch(connection=connection)
|
@@ -0,0 +1,42 @@
|
|
1
|
+
import logging
|
2
|
+
from time import perf_counter
|
3
|
+
|
4
|
+
from panther.base_websocket import Websocket
|
5
|
+
from panther.middlewares import HTTPMiddleware, WebsocketMiddleware
|
6
|
+
from panther.request import Request
|
7
|
+
|
8
|
+
logger = logging.getLogger('monitoring')
|
9
|
+
|
10
|
+
|
11
|
+
class MonitoringMiddleware(HTTPMiddleware):
|
12
|
+
"""
|
13
|
+
Create Log Message Like Below:
|
14
|
+
date_time | method | path | ip:port | response_time(seconds) | status
|
15
|
+
"""
|
16
|
+
|
17
|
+
async def __call__(self, request: Request):
|
18
|
+
start_time = perf_counter()
|
19
|
+
method = request.scope['method']
|
20
|
+
|
21
|
+
response = await self.dispatch(request=request)
|
22
|
+
|
23
|
+
response_time = perf_counter() - start_time # Seconds
|
24
|
+
logger.info(f'{method} | {request.path} | {request.client} | {response_time} | {response.status_code}')
|
25
|
+
return response
|
26
|
+
|
27
|
+
class WebsocketMonitoringMiddleware(WebsocketMiddleware):
|
28
|
+
"""
|
29
|
+
Create Log Message Like Below:
|
30
|
+
date_time | WS | path | ip:port | connection_time(seconds) | status
|
31
|
+
"""
|
32
|
+
ConnectedConnectionTime = ' - '
|
33
|
+
|
34
|
+
async def __call__(self, connection: Websocket):
|
35
|
+
start_time = perf_counter()
|
36
|
+
|
37
|
+
logger.info(f'WS | {connection.path} | {connection.client} |{self.ConnectedConnectionTime}| {connection.state}')
|
38
|
+
connection = await self.dispatch(connection=connection)
|
39
|
+
|
40
|
+
connection_time = perf_counter() - start_time # Seconds
|
41
|
+
logger.info(f'WS | {connection.path} | {connection.client} | {connection_time} | {connection.state}')
|
42
|
+
return connection
|
@@ -0,0 +1 @@
|
|
1
|
+
from panther.openapi.utils import OutputSchema
|
@@ -0,0 +1,27 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html lang="en">
|
3
|
+
<head>
|
4
|
+
<meta charset="UTF-8">
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6
|
+
<title>Swagger UI</title>
|
7
|
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.16.0/swagger-ui.css" />
|
8
|
+
</head>
|
9
|
+
<body>
|
10
|
+
<div id="swagger-ui"></div>
|
11
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.16.0/swagger-ui-bundle.js"></script>
|
12
|
+
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist/swagger-ui-standalone-preset.js"></script>
|
13
|
+
<script>
|
14
|
+
const openapiContent = {{ openapi_content | tojson }};
|
15
|
+
|
16
|
+
const ui = SwaggerUIBundle({
|
17
|
+
spec: openapiContent,
|
18
|
+
dom_id: '#swagger-ui',
|
19
|
+
presets: [
|
20
|
+
SwaggerUIBundle.presets.apis,
|
21
|
+
SwaggerUIStandalonePreset
|
22
|
+
],
|
23
|
+
layout: "StandaloneLayout",
|
24
|
+
});
|
25
|
+
</script>
|
26
|
+
</body>
|
27
|
+
</html>
|
panther/openapi/urls.py
ADDED
panther/openapi/utils.py
ADDED
@@ -0,0 +1,167 @@
|
|
1
|
+
import ast
|
2
|
+
import inspect
|
3
|
+
|
4
|
+
import pydantic
|
5
|
+
|
6
|
+
from panther import status
|
7
|
+
from panther.serializer import ModelSerializer
|
8
|
+
|
9
|
+
|
10
|
+
class EmptyResponseModel(pydantic.BaseModel):
|
11
|
+
pass
|
12
|
+
|
13
|
+
|
14
|
+
class OutputSchema:
|
15
|
+
"""
|
16
|
+
Its values only used in OpenAPI response schema
|
17
|
+
"""
|
18
|
+
|
19
|
+
def __init__(
|
20
|
+
self,
|
21
|
+
model: type[ModelSerializer] | type[pydantic.BaseModel] = EmptyResponseModel,
|
22
|
+
status_code: int = status.HTTP_200_OK,
|
23
|
+
):
|
24
|
+
self.model = model
|
25
|
+
self.status_code = status_code
|
26
|
+
|
27
|
+
|
28
|
+
class ParseEndpoint:
|
29
|
+
"""
|
30
|
+
ParseEndpoint parses the endpoint (function-base/ class-base) and finds where it returns
|
31
|
+
and extract the `data` and `status_code` values.
|
32
|
+
"""
|
33
|
+
|
34
|
+
def __init__(self, endpoint, method):
|
35
|
+
self.tree = ast.parse(inspect.getsource(endpoint))
|
36
|
+
self.method = method
|
37
|
+
|
38
|
+
self.status_code = status.HTTP_200_OK
|
39
|
+
self.title = None
|
40
|
+
self.data = {}
|
41
|
+
|
42
|
+
self.parse()
|
43
|
+
|
44
|
+
def parse(self):
|
45
|
+
for branch in self.tree.body:
|
46
|
+
match branch:
|
47
|
+
case ast.ClassDef(name=name, body=body):
|
48
|
+
# class ...(GenericAPI):
|
49
|
+
# def get(...
|
50
|
+
self.title = name
|
51
|
+
for part in body:
|
52
|
+
match part:
|
53
|
+
case ast.FunctionDef(name=name, body=function_body):
|
54
|
+
# def get(...
|
55
|
+
if name == self.method:
|
56
|
+
self.parse_function(body=function_body)
|
57
|
+
break
|
58
|
+
case ast.AsyncFunctionDef(name=name, body=function_body):
|
59
|
+
# async def get(...
|
60
|
+
if name == self.method:
|
61
|
+
self.parse_function(body=function_body)
|
62
|
+
break
|
63
|
+
|
64
|
+
case ast.FunctionDef(name=name, body=body):
|
65
|
+
# def api(...
|
66
|
+
self.title = name
|
67
|
+
self.parse_function(body=body)
|
68
|
+
|
69
|
+
case ast.AsyncFunctionDef(name=name, body=body):
|
70
|
+
# async def api(...
|
71
|
+
self.title = name
|
72
|
+
self.parse_function(body=body)
|
73
|
+
|
74
|
+
def parse_function(self, body):
|
75
|
+
for part in body:
|
76
|
+
match part:
|
77
|
+
case ast.Return(value=ast.Dict(keys=keys, values=values)):
|
78
|
+
# return {...}
|
79
|
+
self.status_code = 200
|
80
|
+
self.parse_dict_response(keys=keys, values=values)
|
81
|
+
|
82
|
+
case ast.Return(value=ast.Name(id=name)):
|
83
|
+
# return my_data
|
84
|
+
self.status_code = 200
|
85
|
+
for value in self.searching_variable(body=body, name=name):
|
86
|
+
match value:
|
87
|
+
# my_data = {...}
|
88
|
+
case ast.Dict(keys=keys, values=values):
|
89
|
+
self.parse_dict_response(keys=keys, values=values)
|
90
|
+
|
91
|
+
case ast.Return(value=ast.Call(args=args, keywords=keywords, func=func)):
|
92
|
+
if func.id == 'TemplateResponse':
|
93
|
+
return
|
94
|
+
# return Response(...
|
95
|
+
self.parse_response(body=body, args=args, keywords=keywords)
|
96
|
+
|
97
|
+
def parse_dict_response(self, keys, values):
|
98
|
+
for k, v in zip(keys, values):
|
99
|
+
final_value = None
|
100
|
+
match v:
|
101
|
+
case ast.Constant(value=value):
|
102
|
+
final_value = value
|
103
|
+
self.data[k.value] = final_value
|
104
|
+
|
105
|
+
def parse_response(self, body, args, keywords):
|
106
|
+
for keyword in keywords:
|
107
|
+
if keyword.arg == 'data':
|
108
|
+
self.parse_data(body=body, value=keyword.value)
|
109
|
+
if keyword.arg == 'status_code':
|
110
|
+
self.parse_status_code(body=body, value=keyword.value)
|
111
|
+
|
112
|
+
for i, arg in enumerate(args):
|
113
|
+
if i == 0: # index 0 is `data`
|
114
|
+
self.parse_data(body=body, value=arg)
|
115
|
+
elif i == 1: # index 1 is `status_code`
|
116
|
+
self.parse_status_code(body=body, value=arg)
|
117
|
+
|
118
|
+
def parse_status_code(self, body, value):
|
119
|
+
match value:
|
120
|
+
# return Response(?, status_code=my_status)
|
121
|
+
# return Response(?, my_status)
|
122
|
+
case ast.Name():
|
123
|
+
for inner_value in self.searching_variable(body=body, name=value.id):
|
124
|
+
match inner_value:
|
125
|
+
# my_status = status.HTTP_202_ACCEPTED
|
126
|
+
case ast.Attribute(value=inner_inner_value, attr=attr):
|
127
|
+
if inner_inner_value.id == 'status':
|
128
|
+
self.status_code = getattr(status, attr)
|
129
|
+
# my_status = 202
|
130
|
+
case ast.Constant(value=inner_inner_value):
|
131
|
+
self.status_code = inner_inner_value
|
132
|
+
|
133
|
+
# return Response(?, status_code=status.HTTP_202_ACCEPTED)
|
134
|
+
# return Response(?, status.HTTP_202_ACCEPTED)
|
135
|
+
case ast.Attribute(value=value, attr=attr):
|
136
|
+
if value.id == 'status':
|
137
|
+
self.status_code = getattr(status, attr)
|
138
|
+
# return Response(?, status_code=202)
|
139
|
+
# return Response(?, 202)
|
140
|
+
case ast.Constant(value=value):
|
141
|
+
self.status_code = value
|
142
|
+
|
143
|
+
def parse_data(self, body, value):
|
144
|
+
match value:
|
145
|
+
# return Response(data=my_data, ?)
|
146
|
+
# return Response(my_data, ?)
|
147
|
+
case ast.Name():
|
148
|
+
for value in self.searching_variable(body=body, name=value.id):
|
149
|
+
match value:
|
150
|
+
# my_data = {...}
|
151
|
+
case ast.Dict(keys=keys, values=values):
|
152
|
+
self.parse_dict_response(keys=keys, values=values)
|
153
|
+
|
154
|
+
# return Response(data={...}, ?)
|
155
|
+
# return Response({...}, ?)
|
156
|
+
case ast.Dict(keys=keys, values=values):
|
157
|
+
self.parse_dict_response(keys=keys, values=values)
|
158
|
+
|
159
|
+
def searching_variable(self, body, name):
|
160
|
+
for part in body:
|
161
|
+
match part:
|
162
|
+
case ast.Assign(targets=targets, value=value):
|
163
|
+
for target in targets:
|
164
|
+
match target:
|
165
|
+
case ast.Name(id=inner_name):
|
166
|
+
if inner_name == name:
|
167
|
+
yield value
|
panther/openapi/views.py
ADDED
@@ -0,0 +1,101 @@
|
|
1
|
+
import types
|
2
|
+
|
3
|
+
from panther.app import GenericAPI
|
4
|
+
from panther.configs import config
|
5
|
+
from panther.openapi.utils import ParseEndpoint
|
6
|
+
from panther.response import TemplateResponse
|
7
|
+
|
8
|
+
|
9
|
+
class OpenAPI(GenericAPI):
|
10
|
+
@classmethod
|
11
|
+
def get_content(cls, endpoint, method):
|
12
|
+
parsed = ParseEndpoint(endpoint=endpoint, method=method)
|
13
|
+
|
14
|
+
if endpoint.output_schema:
|
15
|
+
status_code = endpoint.output_schema.status_code
|
16
|
+
schema = endpoint.output_schema.model.schema()
|
17
|
+
else:
|
18
|
+
status_code = parsed.status_code
|
19
|
+
schema = {
|
20
|
+
'properties': {
|
21
|
+
k: {'default': v} for k, v in parsed.data.items()
|
22
|
+
}
|
23
|
+
}
|
24
|
+
|
25
|
+
responses = {}
|
26
|
+
if schema:
|
27
|
+
responses = {
|
28
|
+
'responses': {
|
29
|
+
status_code: {
|
30
|
+
'content': {
|
31
|
+
'application/json': {
|
32
|
+
'schema': schema
|
33
|
+
}
|
34
|
+
}
|
35
|
+
}
|
36
|
+
}
|
37
|
+
}
|
38
|
+
request_body = {}
|
39
|
+
if endpoint.input_model and method in ['post', 'put', 'patch']:
|
40
|
+
request_body = {
|
41
|
+
'requestBody': {
|
42
|
+
'required': True,
|
43
|
+
'content': {
|
44
|
+
'application/json': {
|
45
|
+
'schema': endpoint.input_model.schema() if endpoint.input_model else {}
|
46
|
+
}
|
47
|
+
}
|
48
|
+
}
|
49
|
+
}
|
50
|
+
|
51
|
+
content = {
|
52
|
+
'title': parsed.title,
|
53
|
+
'summary': endpoint.__doc__,
|
54
|
+
'tags': ['.'.join(endpoint.__module__.rsplit('.')[:-1]) or endpoint.__module__],
|
55
|
+
} | responses | request_body
|
56
|
+
return {method: content}
|
57
|
+
|
58
|
+
def get(self):
|
59
|
+
paths = {}
|
60
|
+
# TODO:
|
61
|
+
# Try to process the endpoint with `ast` if output_schema is None
|
62
|
+
# Create Component for output_schema.model and input_model
|
63
|
+
#
|
64
|
+
for url, endpoint in config.FLAT_URLS.items():
|
65
|
+
if url == '':
|
66
|
+
url = '/'
|
67
|
+
if not url.startswith('/'):
|
68
|
+
url = f'/{url}'
|
69
|
+
paths[url] = {}
|
70
|
+
|
71
|
+
if isinstance(endpoint, types.FunctionType):
|
72
|
+
methods = endpoint.methods
|
73
|
+
if methods is None or 'POST' in methods:
|
74
|
+
paths[url] |= self.get_content(endpoint, 'post')
|
75
|
+
if methods is None or 'GET' in methods:
|
76
|
+
paths[url] |= self.get_content(endpoint, 'get')
|
77
|
+
if methods is None or 'PUT' in methods:
|
78
|
+
paths[url] |= self.get_content(endpoint, 'put')
|
79
|
+
if methods is None or 'PATCH' in methods:
|
80
|
+
paths[url] |= self.get_content(endpoint, 'patch')
|
81
|
+
if methods is None or 'DELETE' in methods:
|
82
|
+
paths[url] |= self.get_content(endpoint, 'delete')
|
83
|
+
else:
|
84
|
+
if endpoint.post is not GenericAPI.post:
|
85
|
+
paths[url] |= self.get_content(endpoint, 'post')
|
86
|
+
if endpoint.get is not GenericAPI.get:
|
87
|
+
paths[url] |= self.get_content(endpoint, 'get')
|
88
|
+
if endpoint.put is not GenericAPI.put:
|
89
|
+
paths[url] |= self.get_content(endpoint, 'put')
|
90
|
+
if endpoint.patch is not GenericAPI.patch:
|
91
|
+
paths[url] |= self.get_content(endpoint, 'patch')
|
92
|
+
if endpoint.delete is not GenericAPI.delete:
|
93
|
+
paths[url] |= self.get_content(endpoint, 'delete')
|
94
|
+
|
95
|
+
openapi_content = {
|
96
|
+
'openapi': '3.0.0',
|
97
|
+
'paths': paths,
|
98
|
+
'components': {}
|
99
|
+
}
|
100
|
+
print(f'{openapi_content=}')
|
101
|
+
return TemplateResponse(name='openapi.html', context={'openapi_content': openapi_content})
|
panther/pagination.py
CHANGED
@@ -0,0 +1,10 @@
|
|
1
|
+
from panther.middlewares import HTTPMiddleware
|
2
|
+
from panther.request import Request
|
3
|
+
from panther.response import RedirectResponse
|
4
|
+
|
5
|
+
|
6
|
+
class RedirectToSlashMiddleware(HTTPMiddleware):
|
7
|
+
async def __call__(self, request: Request):
|
8
|
+
if not request.path.endswith('/'):
|
9
|
+
return RedirectResponse(request.path + '/')
|
10
|
+
return await self.dispatch(request=request)
|
@@ -0,0 +1,14 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html lang="en">
|
3
|
+
|
4
|
+
<head>
|
5
|
+
<meta charset="UTF-8" />
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
7
|
+
<title>{{ title }}</title>
|
8
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
9
|
+
</head>
|
10
|
+
<body class="bg-gray-900 text-white p-8">
|
11
|
+
{% block content %}
|
12
|
+
{% endblock %}
|
13
|
+
</body>
|
14
|
+
</html>
|
@@ -0,0 +1,21 @@
|
|
1
|
+
{% extends "base.html" %}
|
2
|
+
|
3
|
+
{% block content %}
|
4
|
+
<div class="max-w-4xl mx-auto">
|
5
|
+
<h1 class="text-2xl font-semibold mb-6">Create New Record</h1>
|
6
|
+
|
7
|
+
<form id="createForm" class="space-y-4 bg-gray-800 p-6 rounded-lg">
|
8
|
+
<div id="dynamicInputs" class="space-y-4">
|
9
|
+
<!-- Dynamic inputs will be generated here -->
|
10
|
+
</div>
|
11
|
+
<button type="submit" class="w-full bg-blue-600 hover:bg-blue-500 text-white py-2 px-4 rounded-lg">
|
12
|
+
Submit
|
13
|
+
</button>
|
14
|
+
</form>
|
15
|
+
</div>
|
16
|
+
<script>
|
17
|
+
const isUpdate = false;
|
18
|
+
const data = {};
|
19
|
+
{% include "create.js" %}
|
20
|
+
</script>
|
21
|
+
{% endblock %}
|