lapis-api 0.2.0__tar.gz

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.
Files changed (31) hide show
  1. lapis_api-0.2.0/.github/CONTRIBUTIONS.md +0 -0
  2. lapis_api-0.2.0/.github/workflows/pylint.yml +23 -0
  3. lapis_api-0.2.0/.github/workflows/python-publish.yml +70 -0
  4. lapis_api-0.2.0/.gitignore +2 -0
  5. lapis_api-0.2.0/.pylintrc +2 -0
  6. lapis_api-0.2.0/LICENSE +21 -0
  7. lapis_api-0.2.0/PKG-INFO +47 -0
  8. lapis_api-0.2.0/README.md +32 -0
  9. lapis_api-0.2.0/pyproject.toml +30 -0
  10. lapis_api-0.2.0/src/Lapis.egg-info/PKG-INFO +14 -0
  11. lapis_api-0.2.0/src/Lapis.egg-info/SOURCES.txt +10 -0
  12. lapis_api-0.2.0/src/Lapis.egg-info/dependency_links.txt +1 -0
  13. lapis_api-0.2.0/src/Lapis.egg-info/top_level.txt +1 -0
  14. lapis_api-0.2.0/src/lapis/__init__.py +10 -0
  15. lapis_api-0.2.0/src/lapis/lapis.py +273 -0
  16. lapis_api-0.2.0/src/lapis/protocols/http1.py +251 -0
  17. lapis_api-0.2.0/src/lapis/protocols/websocket.py +544 -0
  18. lapis_api-0.2.0/src/lapis/server_types.py +172 -0
  19. lapis_api-0.2.0/tests/basic_load_test/api/other/path.py +4 -0
  20. lapis_api-0.2.0/tests/basic_load_test/api/path.py +4 -0
  21. lapis_api-0.2.0/tests/basic_load_test/test.py +5 -0
  22. lapis_api-0.2.0/tests/server_config_test/config.json +4 -0
  23. lapis_api-0.2.0/tests/server_config_test/other/api/path.py +4 -0
  24. lapis_api-0.2.0/tests/server_config_test/test.py +6 -0
  25. lapis_api-0.2.0/tests/slugs_test/api/[slug1]/other/[slug2]/path.py +4 -0
  26. lapis_api-0.2.0/tests/slugs_test/api/[slug1]/path.py +4 -0
  27. lapis_api-0.2.0/tests/slugs_test/test.py +5 -0
  28. lapis_api-0.2.0/tests/streamed_response_test/api/path.py +15 -0
  29. lapis_api-0.2.0/tests/streamed_response_test/test.py +5 -0
  30. lapis_api-0.2.0/tests/websocket_test/api/path.py +15 -0
  31. lapis_api-0.2.0/tests/websocket_test/test.py +5 -0
File without changes
@@ -0,0 +1,23 @@
1
+ name: Pylint
2
+
3
+ on: [push]
4
+
5
+ jobs:
6
+ build:
7
+ runs-on: ubuntu-latest
8
+ strategy:
9
+ matrix:
10
+ python-version: ["3.8", "3.9", "3.10"]
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - name: Set up Python ${{ matrix.python-version }}
14
+ uses: actions/setup-python@v3
15
+ with:
16
+ python-version: ${{ matrix.python-version }}
17
+ - name: Install dependencies
18
+ run: |
19
+ python -m pip install --upgrade pip
20
+ pip install pylint
21
+ - name: Analysing the code with pylint
22
+ run: |
23
+ pylint .
@@ -0,0 +1,70 @@
1
+ # This workflow will upload a Python Package to PyPI when a release is created
2
+ # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
3
+
4
+ # This workflow uses actions that are not certified by GitHub.
5
+ # They are provided by a third-party and are governed by
6
+ # separate terms of service, privacy policy, and support
7
+ # documentation.
8
+
9
+ name: Upload Python Package
10
+
11
+ on:
12
+ release:
13
+ types: [published]
14
+
15
+ permissions:
16
+ contents: read
17
+
18
+ jobs:
19
+ release-build:
20
+ runs-on: ubuntu-latest
21
+
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+
25
+ - uses: actions/setup-python@v5
26
+ with:
27
+ python-version: "3.x"
28
+
29
+ - name: Build release distributions
30
+ run: |
31
+ # NOTE: put your own distribution build steps here.
32
+ python -m pip install build
33
+ python -m build
34
+
35
+ - name: Upload distributions
36
+ uses: actions/upload-artifact@v4
37
+ with:
38
+ name: release-dists
39
+ path: dist/
40
+
41
+ pypi-publish:
42
+ runs-on: ubuntu-latest
43
+ needs:
44
+ - release-build
45
+ permissions:
46
+ # IMPORTANT: this permission is mandatory for trusted publishing
47
+ id-token: write
48
+
49
+ # Dedicated environments with protections for publishing are strongly recommended.
50
+ # For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules
51
+ environment:
52
+ name: pypi
53
+ # OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status:
54
+ # url: https://pypi.org/p/YOURPROJECT
55
+ #
56
+ # ALTERNATIVE: if your GitHub Release name is the PyPI project version string
57
+ # ALTERNATIVE: exactly, uncomment the following line instead:
58
+ # url: https://pypi.org/project/YOURPROJECT/${{ github.event.release.name }}
59
+
60
+ steps:
61
+ - name: Retrieve release distributions
62
+ uses: actions/download-artifact@v4
63
+ with:
64
+ name: release-dists
65
+ path: dist/
66
+
67
+ - name: Publish release distributions to PyPI
68
+ uses: pypa/gh-action-pypi-publish@release/v1
69
+ with:
70
+ packages-dir: dist/
@@ -0,0 +1,2 @@
1
+ __pycache__/
2
+ venv/
@@ -0,0 +1,2 @@
1
+ [MASTER]
2
+ ignore=tests
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) [2025] [Chandler Van]
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: lapis-api
3
+ Version: 0.2.0
4
+ Summary: An organized way to create REST APIs
5
+ Project-URL: Homepage, https://github.com/CQVan/Lapis
6
+ Project-URL: Issues, https://github.com/CQVan/Lapis/issues
7
+ Author: Chandler Van
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+
16
+ Lapis is a file-based REST API framework inspired by Modern Serverless Cloud Services
17
+
18
+ To create a basic Lapis server, create a folder called *api* and create python script named *path.py*
19
+ then within your main folder create a python script to start the server (we will call this script *main.py* in our example)
20
+
21
+ Your project directory should look like this:
22
+
23
+ ```
24
+ project-root/
25
+ |-- api/
26
+ | |-- path.py
27
+ `-- main.py
28
+ ```
29
+
30
+ Then within the *api/path.py* file create your first GET api endpoint by adding the following code:
31
+ ```py
32
+ from lapis import Response, Request
33
+
34
+ async def GET (req : Request) -> Response:
35
+ return Response(status_code=200, body="Hello World!")
36
+ ```
37
+
38
+ Finally by adding the following code to *main.py* and running it:
39
+ ```py
40
+ from lapis import Lapis
41
+
42
+ server = Lapis()
43
+
44
+ server.run("localhost", 80)
45
+ ```
46
+
47
+ You can now send an HTTP GET request to localhost:80 and recieve the famous **Hello World!** response!
@@ -0,0 +1,32 @@
1
+ Lapis is a file-based REST API framework inspired by Modern Serverless Cloud Services
2
+
3
+ To create a basic Lapis server, create a folder called *api* and create python script named *path.py*
4
+ then within your main folder create a python script to start the server (we will call this script *main.py* in our example)
5
+
6
+ Your project directory should look like this:
7
+
8
+ ```
9
+ project-root/
10
+ |-- api/
11
+ | |-- path.py
12
+ `-- main.py
13
+ ```
14
+
15
+ Then within the *api/path.py* file create your first GET api endpoint by adding the following code:
16
+ ```py
17
+ from lapis import Response, Request
18
+
19
+ async def GET (req : Request) -> Response:
20
+ return Response(status_code=200, body="Hello World!")
21
+ ```
22
+
23
+ Finally by adding the following code to *main.py* and running it:
24
+ ```py
25
+ from lapis import Lapis
26
+
27
+ server = Lapis()
28
+
29
+ server.run("localhost", 80)
30
+ ```
31
+
32
+ You can now send an HTTP GET request to localhost:80 and recieve the famous **Hello World!** response!
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["hatchling >= 1.26"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [tool.hatch.build.targets.wheel]
6
+ packages = ["src/lapis"]
7
+
8
+ [project]
9
+ name = "lapis-api"
10
+ version = "0.2.0"
11
+ description = "An organized way to create REST APIs"
12
+ readme = "README.md"
13
+ requires-python = ">=3.9"
14
+ authors = [
15
+ { name = "Chandler Van" },
16
+ ]
17
+
18
+
19
+
20
+ license = {text = "MIT"}
21
+
22
+ classifiers = [
23
+ "Programming Language :: Python :: 3",
24
+ "Operating System :: OS Independent",
25
+ "License :: OSI Approved :: MIT License",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/CQVan/Lapis"
30
+ Issues = "https://github.com/CQVan/Lapis/issues"
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: Lapis
3
+ Version: 0.1.0
4
+ Summary: A small example package
5
+ Author: Chandler Van
6
+ Project-URL: Homepage, https://github.com/CQVan/Lapis
7
+ Project-URL: Issues, https://github.com/CQVan/Lapis/issues
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE.bak
14
+ Dynamic: license-file
@@ -0,0 +1,10 @@
1
+ LICENSE.bak
2
+ README.md
3
+ pyproject.toml
4
+ src/Lapis.egg-info/PKG-INFO
5
+ src/Lapis.egg-info/SOURCES.txt
6
+ src/Lapis.egg-info/dependency_links.txt
7
+ src/Lapis.egg-info/top_level.txt
8
+ src/lapis/__init__.py
9
+ src/lapis/lapis.py
10
+ src/lapis/server_types.py
@@ -0,0 +1 @@
1
+ lapis
@@ -0,0 +1,10 @@
1
+ """Lapis web framework core exports.
2
+
3
+ This module exposes the primary public classes for consumers of the
4
+ lapis package.
5
+ """
6
+
7
+ from .lapis import Lapis
8
+ from .server_types import ServerConfig, Protocol
9
+ from .protocols.http1 import Request, Response, StreamedResponse
10
+ from .protocols.websocket import WebSocketProtocol, WSPortal
@@ -0,0 +1,273 @@
1
+ """
2
+ The main script for the Lapis server handling initial request handling and response
3
+ """
4
+
5
+ import asyncio
6
+ import inspect
7
+ import select
8
+ import socket
9
+ import pathlib
10
+ import runpy
11
+ import sys
12
+ from threading import Thread
13
+ from datetime import datetime
14
+
15
+ from lapis.protocols.websocket import WebSocketProtocol
16
+ from lapis.protocols.http1 import HTTP1Protocol, Request, Response
17
+ from .server_types import (
18
+ BadAPIDirectory,
19
+ BadConfigError,
20
+ BadRequest,
21
+ Protocol,
22
+ ServerConfig,
23
+ ProtocolEndpointError
24
+ )
25
+
26
+ class Lapis:
27
+ """
28
+ The Lapis class implements the centeral object used to run a Lapis REST server
29
+ """
30
+ cfg: ServerConfig = ServerConfig()
31
+
32
+ __s: socket.socket = None
33
+ __paths: dict = {}
34
+ __taken_endpoints : list[str] = []
35
+ __protocols : list[type[Protocol]] = []
36
+
37
+ __running : bool = False
38
+
39
+ def __init__(self, config: ServerConfig | None = None):
40
+
41
+ if config is not None:
42
+ self.cfg = config
43
+
44
+ self.__register_protocol(HTTP1Protocol)
45
+ self.__register_protocol(WebSocketProtocol)
46
+
47
+ self.__paths = self._bake_paths()
48
+
49
+ def run(self, ip: str, port: int):
50
+ """
51
+ Starts the Lapis server to listen on a given ip and port
52
+
53
+ :param ip: The ip for the server to listen on
54
+ :type ip: str
55
+ :param port: The port for the server to listen on
56
+ :type port: int
57
+ """
58
+ self.__s = socket.socket()
59
+ self.__s.bind((ip, port))
60
+ self.__s.listen()
61
+
62
+ self.__running = True
63
+ print(f"{self.cfg.server_name} is now listening on http://{ip}:{port}")
64
+
65
+ try:
66
+ while True:
67
+ readable, _, _ = select.select([self.__s], [], [], 0.1)
68
+ if self.__s in readable:
69
+ client, _ = self.__s.accept()
70
+ t = Thread(target=self._handle_request, args=(client,), daemon=True)
71
+ t.start()
72
+ except KeyboardInterrupt:
73
+ pass
74
+ finally:
75
+ self.__close()
76
+
77
+ def __register_protocol(self, protocol : type[Protocol]):
78
+
79
+ if self.__running:
80
+ raise RuntimeError("Cannot register new Protocol while server is running")
81
+
82
+ endpoints : list[str] = protocol().get_target_endpoints()
83
+ if bool(set(endpoints) & set(self.__taken_endpoints)):
84
+ raise ProtocolEndpointError("Cannot reuse target endpoint method!")
85
+
86
+ self.__protocols.insert(0, protocol)
87
+ self.__taken_endpoints.extend(endpoints)
88
+
89
+ def register_protocol(self, protocol : type[Protocol]):
90
+ """
91
+ Registers new protocol for the server to use to communicate with clients
92
+
93
+ Cannot be called while server is running
94
+
95
+ :param protocol: Description
96
+ :type protocol: type[Protocol]
97
+ """
98
+ self.__register_protocol(protocol=protocol)
99
+
100
+ self.__paths = self._bake_paths()
101
+
102
+ def _get_dynamic_dirs(self, directory: pathlib.Path):
103
+ return [
104
+ p for p in directory.iterdir()
105
+ if p.is_dir() and p.name.startswith("[") and p.name.endswith("]")
106
+ ]
107
+
108
+ def _bake_paths(self) -> dict:
109
+
110
+ server_path = pathlib.Path(sys.argv[0]).resolve()
111
+ root : pathlib.Path = server_path.parent / pathlib.Path(self.cfg.api_directory)
112
+
113
+ try:
114
+ root.parent.resolve(strict=False)
115
+ except (OSError, RuntimeError) as err:
116
+ raise BadConfigError("\"api_directory\" in config must be a valid file path") from err
117
+
118
+ if not root.exists():
119
+ raise BadAPIDirectory(f"api directory \"{root}\" does not exist")
120
+
121
+ result = {}
122
+
123
+ for path in root.rglob(f"{self.cfg.path_script_name}.py"):
124
+ if not path.is_file():
125
+ continue
126
+
127
+ parts = path.relative_to(root).parts
128
+ current_level = result
129
+ current_fs_level = root
130
+
131
+ for part in parts[:-1]:
132
+ dynamic_dirs = self._get_dynamic_dirs(current_fs_level)
133
+ if len(dynamic_dirs) > 1:
134
+ raise BadAPIDirectory(
135
+ f"Multiple dynamic route folders in {current_fs_level}: "
136
+ f"{', '.join(d.name for d in dynamic_dirs)}"
137
+ )
138
+
139
+ # move filesystem pointer
140
+ current_fs_level = current_fs_level / part
141
+
142
+ current_level = current_level.setdefault(part, {})
143
+
144
+ script_globals = runpy.run_path(str(path.absolute()))
145
+
146
+ # Grab just endpoint methods
147
+ api_routes = {
148
+ f"/{k}": v
149
+ for k, v in script_globals.items()
150
+ if k in self.__taken_endpoints
151
+ }
152
+
153
+ # Add endpoints
154
+ current_level.update(api_routes)
155
+ return result
156
+
157
+ def __has_endpoint_path(self, base_url : str) -> tuple[dict[str, any] | None, dict[str,str]]:
158
+ # Digs through api cache map to find the correct endpoint directory
159
+ slugs = {}
160
+ path = pathlib.Path(f"{self.cfg.api_directory}{base_url}")
161
+ parts : list[str] = path.relative_to(self.cfg.api_directory).parts
162
+
163
+ leaf : dict = self.__paths
164
+ for part in parts:
165
+ if part in leaf:
166
+ leaf = leaf[part]
167
+ continue
168
+
169
+ # checks if there are dynamic routes available
170
+ dynamic_routes: list[str] = list(
171
+ {
172
+ key
173
+ for key in leaf
174
+ if key.startswith("[") and key.endswith("]")
175
+ }
176
+ )
177
+
178
+ if len(dynamic_routes) == 1:
179
+ slugs[dynamic_routes[0].strip("[]")] = part
180
+ leaf = leaf[dynamic_routes[0]]
181
+ else:
182
+ return (None, {})
183
+
184
+ if len(leaf) == 0:
185
+ return (None, {})
186
+
187
+ return (leaf, slugs)
188
+
189
+ def _handle_request(self, client: socket.socket):
190
+ data = client.recv(self.cfg.max_request_size)
191
+
192
+ try:
193
+ request : Request = Request(data)
194
+
195
+ except BadRequest:
196
+ self.__send_response(client, Response(status_code=400, body="400 Bad Request"))
197
+ client.close()
198
+ return
199
+
200
+ try:
201
+ (endpoint, request.slugs) = self.__has_endpoint_path(request.base_url)
202
+
203
+ if endpoint is None:
204
+ raise FileNotFoundError()
205
+
206
+ # Finds the correct protocol based on the inital request
207
+ for protocol_cls in self.__protocols:
208
+ protocol: Protocol = protocol_cls()
209
+
210
+ if not protocol.identify(initial_data=data):
211
+ continue
212
+
213
+ if not protocol.handshake(client=client):
214
+ raise BadRequest("Failed Handshake with protocol!")
215
+
216
+ target_endpoints = protocol.get_target_endpoints()
217
+
218
+ endpoints = {
219
+ f"/{k}": endpoint[f"/{k}"]
220
+ for k in target_endpoints
221
+ if f"/{k}" in endpoint
222
+ }
223
+
224
+ endpoints = { key.lstrip("/"): value for key, value in endpoints.items() }
225
+
226
+ if inspect.iscoroutinefunction(protocol.handle):
227
+ asyncio.run(protocol.handle(
228
+ client=client,
229
+ slugs=request.slugs,
230
+ endpoints=endpoints,
231
+ ))
232
+ else:
233
+ protocol.handle(
234
+ client=client,
235
+ slugs=request.slugs,
236
+ endpoints=endpoints,
237
+ )
238
+
239
+ break
240
+ else: # No Protocol was found to be compatible
241
+ raise BadRequest("No Compatible Protocol Found!")
242
+
243
+
244
+ except BadRequest:
245
+ response : Response = Response(status_code=400, body="400 Bad Request")
246
+ self.__send_response(client=client, response=response)
247
+
248
+ except FileNotFoundError:
249
+ response : Response = Response(status_code=404, body="404 Not Found")
250
+ self.__send_response(client, response)
251
+
252
+ except RuntimeError as e:
253
+ print(f"Error handling request: {e}")
254
+ response = Response(status_code=500, body="Internal Server Error")
255
+ self.__send_response(client, response)
256
+
257
+ finally:
258
+ client.close()
259
+
260
+ def __send_response(self, client : socket.socket, response : Response):
261
+ client.sendall(response.to_bytes())
262
+ current_time = datetime.now().strftime("%H:%M:%S")
263
+ ip, _ = client.getpeername()
264
+ print(f"{current_time} {response.status_code.value} -> {ip}")
265
+
266
+ def __close(self):
267
+ if self.__s is not None:
268
+ try:
269
+ print("Closing Server...")
270
+ self.__running = False
271
+ self.__s.close()
272
+ except socket.error as e:
273
+ print(f"Error when closing socket: {e}")