├── lib
├── easyweb.mpy
├── easynetwork.mpy
├── easyweb_single.mpy
├── easyweb_thread.mpy
├── easynetwork.py
├── easynetwork-english.py
├── easyweb_single.py
├── easyweb_thread.py
└── easyweb.py
├── web
├── EasyWeb_256px.png
├── time.html
├── 404.html
└── wifi.html
├── main.py
├── README.ZH-CN.md
└── README.md
/lib/easyweb.mpy:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/funnygeeker/micropython-easyweb/HEAD/lib/easyweb.mpy
--------------------------------------------------------------------------------
/lib/easynetwork.mpy:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/funnygeeker/micropython-easyweb/HEAD/lib/easynetwork.mpy
--------------------------------------------------------------------------------
/lib/easyweb_single.mpy:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/funnygeeker/micropython-easyweb/HEAD/lib/easyweb_single.mpy
--------------------------------------------------------------------------------
/lib/easyweb_thread.mpy:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/funnygeeker/micropython-easyweb/HEAD/lib/easyweb_thread.mpy
--------------------------------------------------------------------------------
/web/EasyWeb_256px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/funnygeeker/micropython-easyweb/HEAD/web/EasyWeb_256px.png
--------------------------------------------------------------------------------
/web/time.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Time
6 |
7 |
8 | Time: {{time}}
9 |
10 |
--------------------------------------------------------------------------------
/web/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | 404 Not Found
6 |
25 |
26 |
27 |
28 |
404 Not Found
29 |
您访问的网页不存在
30 |
31 |
32 |
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
1 | import time
2 | from lib.easynetwork import Client
3 | from lib.easyweb import EasyWeb, render_template, send_file, make_response
4 |
5 | client = Client()
6 | client.connect("ssid", "password") # 或者 client.connect("ssid", "")
7 |
8 | while not client.isconnected():
9 | pass
10 | print('IP Address: {}'.format(client.ifconfig()[0]))
11 |
12 | ew = EasyWeb() # 初始化 EasyWeb
13 |
14 | # 添加路由
15 | @ew.route('/')
16 | def home(request):
17 | print("=======[request]=======")
18 | print('URL: ', request.url)
19 | print('Args: ', request.args)
20 | print('Form: ', request.form)
21 | print('Host: ', request.host)
22 | print('Json: ', request.json)
23 | print('Path: ', request.path)
24 | print('Header: ', request.headers)
25 | print('Cookies: ', request.cookies)
26 | print('Full_Path: ', request.full_path)
27 | return render_template("/web/wifi.html")
28 |
29 | # 发送文件
30 | @ew.route('/easyweb.png')
31 | def img(request):
32 | # 访问网页的 /easyweb.png 试试?
33 | return send_file("/web/EasyWeb_256px.png")
34 |
35 | # 下载文件
36 | @ew.route('/download')
37 | def download(request):
38 | # 访问网页的 /easyweb.png 试试?
39 | # as_attachment: 是否作为附件发送文件,作为附件时会被下载
40 | # attachment_filename: 下载文件时向用户显示的文件名。如果未提供,将使用原始文件名
41 | return send_file("/web/EasyWeb_256px.png", as_attachment=True, attachment_filename='easyweb.png')
42 |
43 | # 停止 EasyWeb
44 | @ew.route('/stop')
45 | def stop(request):
46 | ew.stop()
47 |
48 | # 获取字符串
49 | @ew.route('/user/')
50 | def user(request):
51 | # 访问网页的 /user/123456 试试?
52 | return "Hello {}
".format(request.match)
53 |
54 | # 获取路径
55 | @ew.route('/path/')
56 | def path(request):
57 | # 访问网页的 /path/123/456 试试?
58 | return "Path {}
".format(request.match)
59 |
60 | # 渲染 HTML
61 | @ew.route('/time')
62 | def the_time(request):
63 | # 访问网页的 /time 然后刷新几遍页面,观察网页的变化
64 | return render_template("/web/time.html", time=time.time())
65 |
66 | # 获取与设置 Cookies
67 | @ew.route('/cookie')
68 | def cookie(request):
69 | # 访问网页的 /cookie 然后刷新几遍页面,观察网页的变化
70 | response = make_response('Cookies: {}
'.format(str(request.cookies)))
71 | response.set_cookie('cookie_name', 'cookie_value')
72 | return response
73 |
74 | # 自定义状态码
75 | @ew.route('/404')
76 | def status_code(request):
77 | # 访问网页的 /404,打开开发人员工具观察状态码
78 | return '404 Not Found
', 404
79 |
80 | # 获取与设置 Cookies,同时自定义状态码
81 | @ew.route('/cookie2')
82 | def cookie2(request):
83 | # 访问网页的 /cookie 然后刷新几遍页面,观察网页的变化
84 | response = make_response() # 也可以在后面为 response.data 赋值,来代替初始化时赋值
85 | response.data = 'Cookies: {}
404 Not Found
'.format(str(request.cookies))
86 | response.set_cookie('cookie_name', 'cookie_value')
87 | return response, 404
88 |
89 | # 获取 JSON 格式的内容
90 | @ew.route('/json')
91 | def cookie2(request):
92 | # 访问网页的 /json
93 | return {'type': 'json', 'num': 123}
94 |
95 | ew.run()
96 | print('======END======') # 访问 /stop
--------------------------------------------------------------------------------
/README.ZH-CN.md:
--------------------------------------------------------------------------------
1 | [English (英语)](./README.md)
2 |
3 | # micropython-easyweb
4 | 
5 | - 适用于 `Micropython` 的:简易 Web Server 库,易用,多功能
6 |
7 | ### 特点
8 | - 尽可能模仿了 `Flask` 框架的风格
9 | - 集成了常用的:GET 请求参数解析,表单解析,HTML渲染,文件发送,Cookies设置,Cookies获取,动态路由 等常用功能
10 |
11 | ### 使用说明
12 | - 本项目一共有三个版本的文件,请根据实际需要进行选择:
13 | - `thread`: `/lib/easyweb_thread.py` 使用多线程实现
14 | - `asyncio`: `/lib/easyweb.py` 使用异步实现,具有较好的兼容性和可靠性
15 | - `single`: `/lib/easyweb_single.py` 使用单线程循环实现,具有较好的兼容性
16 |
17 | ### 兼容性
18 | #### 已通过测试设备
19 | - `ESP-01S`: `single`
20 | - `ESP32`: `single`, `thread`, `asyncio`
21 | - `ESP32-C3`: `single`, `thread`, `asyncio`
22 | - `ESP32-S3`: `single`, `thread`, `asyncio`
23 |
24 |
25 | ### 示例代码
26 | - 这里我们使用 [micropython-easynetwork](https://github.com/funnygeeker/micropython-easynetwork) 作为示例,连接局域网(也可以工作在AP模式,使用其他设备连接开发板)
27 | ```python
28 | import time
29 | from lib.easynetwork import Client
30 | from lib.easyweb import EasyWeb, render_template, send_file, make_response
31 |
32 | client = Client()
33 | client.connect("ssid", "password") # 或者 client.connect("ssid", "")
34 |
35 | while not client.isconnected():
36 | pass
37 | print('IP Address: {}'.format(client.ifconfig()[0]))
38 |
39 | ew = EasyWeb() # 初始化 EasyWeb
40 |
41 | # 添加路由
42 | @ew.route('/')
43 | def home(request):
44 | print("=======[request]=======")
45 | print('URL: ', request.url)
46 | print('Args: ', request.args)
47 | print('Form: ', request.form)
48 | print('Host: ', request.host)
49 | print('Json: ', request.json)
50 | print('Path: ', request.path)
51 | print('Header: ', request.headers)
52 | print('Cookies: ', request.cookies)
53 | print('Full_Path: ', request.full_path)
54 | return render_template("/web/wifi.html")
55 |
56 | # 发送文件
57 | @ew.route('/easyweb.png')
58 | def img(request):
59 | # 访问网页的 /easyweb.png 试试?
60 | return send_file("/web/EasyWeb_256px.png")
61 |
62 | # 下载文件
63 | @ew.route('/download')
64 | def download(request):
65 | # 访问网页的 /easyweb.png 试试?
66 | # as_attachment: 是否作为附件发送文件,作为附件时会被下载
67 | # attachment_filename: 下载文件时向用户显示的文件名。如果未提供,将使用原始文件名
68 | return send_file("/web/EasyWeb_256px.png", as_attachment=True, attachment_filename='easyweb.png')
69 |
70 | # 停止 EasyWeb
71 | @ew.route('/stop')
72 | def stop(request):
73 | ew.stop()
74 |
75 | # 获取字符串
76 | @ew.route('/user/')
77 | def user(request):
78 | # 访问网页的 /user/123456 试试?
79 | return "Hello {}
".format(request.match)
80 |
81 | # 获取路径
82 | @ew.route('/path/')
83 | def path(request):
84 | # 访问网页的 /path/123/456 试试?
85 | return "Path {}
".format(request.match)
86 |
87 | # 渲染 HTML
88 | @ew.route('/time')
89 | def the_time(request):
90 | # 访问网页的 /time 然后刷新几遍页面,观察网页的变化
91 | return render_template("/web/time.html", time=time.time())
92 |
93 | # 获取与设置 Cookies
94 | @ew.route('/cookie')
95 | def cookie(request):
96 | # 访问网页的 /cookie 然后刷新几遍页面,观察网页的变化
97 | response = make_response('Cookies: {}
'.format(str(request.cookies)))
98 | response.set_cookie('cookie_name', 'cookie_value')
99 | return response
100 |
101 | # 自定义状态码
102 | @ew.route('/404')
103 | def status_code(request):
104 | # 访问网页的 /404,打开开发人员工具观察状态码
105 | return '404 Not Found
', 404
106 |
107 | # 获取与设置 Cookies,同时自定义状态码
108 | @ew.route('/cookie2')
109 | def cookie2(request):
110 | # 访问网页的 /cookie 然后刷新几遍页面,观察网页的变化
111 | response = make_response() # 也可以在后面为 response.data 赋值,来代替初始化时赋值
112 | response.data = 'Cookies: {}
404 Not Found
'.format(str(request.cookies))
113 | response.set_cookie('cookie_name', 'cookie_value')
114 | return response, 404
115 |
116 | # 获取 JSON 格式的内容
117 | @ew.route('/json')
118 | def cookie2(request):
119 | # 访问网页的 /json
120 | return {'type': 'json', 'num': 123}
121 |
122 | ew.run()
123 | print('======END======') # 访问 /stop
124 | ```
--------------------------------------------------------------------------------
/web/wifi.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | 配置页面
8 |
70 |
71 |
72 |
73 |
128 |
129 |
130 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [简体中文 (Chinese)](./README.ZH-CN.md)
2 | # micropython-easyweb
3 |
4 | 
5 | - A simple and versatile web server library for `Micropython`, easy to use.
6 |
7 | ### Features
8 | - Aims to mimic the style of the Flask framework as much as possible
9 | - Integrates common functionalities such as GET request parameter parsing, form parsing, HTML rendering, file sending, cookie setting, cookie retrieval, dynamic routing, and more.
10 |
11 | ### Instructions
12 | - There are three versions of the project files, please choose the one that suits your needs:
13 | - `thread`: `/lib/easyweb_thread.py` - implemented with multithreading
14 | - `asyncio`: `/lib/easyweb.py` - implemented with asynchronous support, provides better compatibility and reliability
15 | - `single`: `/lib/easyweb_single.py` - implemented with a single thread loop, provides good compatibility
16 |
17 | ### Compatibility
18 | #### Tested Devices
19 | - `ESP-01S`: `single`
20 | - `ESP32`: `single`, `thread`, `asyncio`
21 | - `ESP32-C3`: `single`, `thread`, `asyncio`
22 | - `ESP32-S3`: `single`, `thread`, `asyncio`
23 |
24 | ### Sample Code
25 | - Here we use [micropython-easynetwork](https://github.com/funnygeeker/micropython-easynetwork) as an example to connect to the local network (it can also work in AP mode, allowing other devices to connect to the development board).
26 | ```python
27 | import time
28 | from lib.easynetwork import Client
29 | from lib.easyweb import EasyWeb, render_template, send_file, make_response
30 |
31 | client = Client()
32 | client.connect("ssid", "password") # or client.connect("ssid", "")
33 |
34 | while not client.isconnected():
35 | pass
36 | print('IP Address: {}'.format(client.ifconfig()[0]))
37 |
38 | ew = EasyWeb() # Initialize EasyWeb
39 |
40 | # Add routes
41 | @ew.route('/')
42 | def home(request):
43 | print("=======[request]=======")
44 | print('URL: ', request.url)
45 | print('Args: ', request.args)
46 | print('Form: ', request.form)
47 | print('Host: ', request.host)
48 | print('Json: ', request.json)
49 | print('Path: ', request.path)
50 | print('Header: ', request.headers)
51 | print('Cookies: ', request.cookies)
52 | print('Full_Path: ', request.full_path)
53 | return render_template("/web/wifi.html")
54 |
55 | # Send file
56 | @ew.route('/easyweb.png')
57 | def img(request):
58 | # Try accessing /easyweb.png on the website
59 | return send_file("/web/EasyWeb_256px.png")
60 |
61 | # Download file
62 | @ew.route('/download')
63 | def download(request):
64 | # Try accessing /easyweb.png on the website
65 | # as_attachment: Whether to send the file as an attachment, it will be downloaded when sent as an attachment
66 | # attachment_filename: The filename displayed to the user when downloading. If not provided, the original filename will be used
67 | return send_file("/web/EasyWeb_256px.png", as_attachment=True, attachment_filename='easyweb.png')
68 |
69 | # Stop EasyWeb
70 | @ew.route('/stop')
71 | def stop(request):
72 | ew.stop()
73 |
74 | # Get string
75 | @ew.route('/user/')
76 | def user(request):
77 | # Try accessing /user/123456 on the website
78 | return "Hello {}
".format(request.match)
79 |
80 | # Get path
81 | @ew.route('/path/')
82 | def path(request):
83 | # Try accessing /path/123/456 on the website
84 | return "Path {}
".format(request.match)
85 |
86 | # Render HTML
87 | @ew.route('/time')
88 | def the_time(request):
89 | # Access /time on the website and refresh the page a few times to observe the changes on the webpage
90 | return render_template("/web/time.html", time=time.time())
91 |
92 | # Get and set Cookies
93 | @ew.route('/cookie')
94 | def cookie(request):
95 | # Access /cookie on the website and refresh the page a few times to observe the changes on the webpage
96 | response = make_response('Cookies: {}
'.format(str(request.cookies)))
97 | response.set_cookie('cookie_name', 'cookie_value')
98 | return response
99 |
100 | # Custom status code
101 | @ew.route('/404')
102 | def status_code(request):
103 | # Access /404 on the website, open the developer tools to observe the status code
104 | return '404 Not Found
', 404
105 |
106 | # Get and set Cookies, while customizing the status code
107 | @ew.route('/cookie2')
108 | def cookie2(request):
109 | # Access /cookie on the website and refresh the page a few times to observe the changes on the webpage
110 | response = make_response() # You can also assign a value to response.data later instead of initializing it during initialization
111 | response.data = 'Cookies: {}
404 Not Found
'.format(str(request.cookies))
112 | response.set_cookie('cookie_name', 'cookie_value')
113 | return response, 404
114 |
115 | # Get content in JSON format
116 | @ew.route('/json')
117 | def cookie2(request):
118 | # Access /json on the website
119 | return {'type': 'json', 'num': 123}
120 |
121 | ew.run()
122 | print('======END======') # Access /stop
123 | ```
--------------------------------------------------------------------------------
/lib/easynetwork.py:
--------------------------------------------------------------------------------
1 | import network
2 |
3 |
4 | def _active(func):
5 | """
6 | 检查 WLAN 是否开启,若未开启,则开启后关闭
7 | """
8 | def change_active(self, *args, **kwargs):
9 | if not self.active(): # wlan 不常用时尽量减小功耗
10 | self.active(True)
11 | result = func(self, *args, **kwargs)
12 | self.active(False)
13 | return result
14 | else:
15 | return func(self, *args, **kwargs)
16 |
17 | return change_active
18 |
19 |
20 | class Client(network.WLAN):
21 | def __init__(self):
22 | super().__init__(network.STA_IF)
23 | self.PM_NONE = 0
24 | self.PM_PERFORMANCE = 1
25 | self.PM_POWERSAVE = 2
26 |
27 | def connect(self, *args, **kwargs):
28 | """
29 | 连接无线网络
30 |
31 | Args:
32 | ssid: 无线网络名称
33 | key: 无线网络密码
34 | bssid: 无线网络名称相符时,连接指定 MAC地址 的设备,(默认不指定)
35 | reconnects: 重新连接尝试次数(int, 0=无,-1=无限制)
36 | """
37 | super().active(True)
38 | super().disconnect()
39 | super().connect(*args, **kwargs)
40 |
41 | @_active
42 | def scan(self) -> list:
43 | """
44 | 扫描无线网络
45 |
46 | Returns:
47 | List[Tuple[bytes, bytes, int, int, int, bool]]
48 | [(ssid, bssid, channel, RSSI, security, hidden), ...]
49 | """
50 | return super().scan()
51 |
52 | @_active
53 | def config(self, *args, **kwargs):
54 | """
55 | 获取或设置常规网络接口参数
56 |
57 | Args:
58 | pm: 电源管理设置
59 | Client.PM_NONE - 高性能模式 (0)
60 | Client.PM_PERFORMANCE - 平衡模式 (1)(默认)
61 | Client.PM_POWERSAVE - 节能模式 (2)
62 | mac: 无线网络 MAC 地址
63 | ssid: 无线网络名称,不能设置(只读)
64 | hostname: 主机名,设置时长度不应超过 32
65 | tx_power: 最大发射功率(dBm),一般范围在:2-21
66 |
67 | Example:
68 | # 获取 PM 配置信息
69 | config = client.config('pm')
70 |
71 | # 设置IP配置信息
72 | client.config(pm = 2)
73 | """
74 | return super().config(*args, **kwargs)
75 |
76 | def active(self, *args, **kwargs):
77 | """
78 | 设置 或 获取 WLAN 的活动状态
79 |
80 | Args:
81 | True or False (不填写参数则为获取)
82 |
83 | Returns:
84 | None / bool
85 | """
86 | return super().active(*args, **kwargs)
87 |
88 | def status(self, *args, **kwargs):
89 | """
90 | 获取网络连接状态
91 |
92 | Returns:
93 | network.STAT_IDLE – 无连接且无活动 (1000)
94 | network.STAT_CONNECTING – 连接进行中 (1001)
95 | network.STAT_BEACON_TIMEOUT – 因广播帧超时而连接失败 (200)
96 | network.STAT_NO_AP_FOUND – 因没有接入点回复而连接失败 (201)
97 | network.STAT_WRONG_PASSWORD – 因密码错误而连接失败 (202)
98 | network.STAT_ASSOC_FAIL – 因关联出现问题而连接失败 (203)
99 | network.STAT_HANDSHAKE_TIMEOUT – 因握手超时而连接失败 (204)
100 | network.STAT_GOT_IP – 连接成功 (1010)
101 | """
102 | return super().status(*args, **kwargs)
103 |
104 | def isconnected(self) -> bool:
105 | """
106 | 网络是否已连接
107 |
108 | Returns:
109 | True: 已连接
110 | False: 未连接
111 | """
112 | return super().isconnected()
113 |
114 | def disconnect(self):
115 | """
116 | 断开连接的网络
117 | """
118 | if super().active():
119 | super().disconnect()
120 | super().active(False) # 关闭 WIFI 接口
121 |
122 | @_active
123 | def ifconfig(self, *args, **kwargs) -> tuple:
124 | """
125 | 获取或设置IP级网络接口参数。
126 |
127 | Args:
128 | 包含IP地址、子网掩码、网关和 DNS服务器的元组。默认值为 None,即获取网络状态
129 |
130 | Returns:
131 | tuple: 包含IP地址、子网掩码、网关和 DNS服务器的元组。
132 |
133 | Example:
134 | # 获取IP配置信息
135 | ip_config = client.ifconfig()
136 |
137 | # 设置IP配置信息
138 | client.ifconfig(('192.168.3.4', '255.255.255.0', '192.168.3.1', '8.8.8.8'))
139 | """
140 | return super().ifconfig(*args, **kwargs)
141 |
142 |
143 | class AP(network.WLAN):
144 | def __init__(self):
145 | super().__init__(network.AP_IF)
146 | self.PM_NONE = 0
147 | self.PM_PERFORMANCE = 1
148 | self.PM_POWERSAVE = 2
149 |
150 | @_active
151 | def config(self, *args, **kwargs):
152 | """
153 | 获取或设置常规网络接口参数
154 |
155 | Args:
156 | pm: 电源管理设置
157 | AP.PM_NONE - 高性能模式 (0)
158 | AP.PM_PERFORMANCE - 平衡模式 (1)(默认)
159 | AP.PM_POWERSAVE - 节能模式 (2)
160 | mac: 无线网络 MAC 地址
161 | key: 连接密码,设置时长度应大于 8,或者为 None, '', 0,修改密码时会自动修改 security(只写,不能读取)
162 | hidden: 是否隐藏
163 | 0 - 可见
164 | 1 - 隐藏
165 | channel: 信道
166 | 一般为 1 - 13
167 | security: 认证模式
168 | 0 - Open
169 | 1 - WEP
170 | 2 - WPA-PSK
171 | 3 - WPA2-PSK
172 | 4 - WPA/WPA2-PSK
173 | ssid: 无线网络名称
174 | hostname: 主机名,设置时长度不应超过 32
175 | tx_power: 最大发射功率(dBm),一般范围在:2-21
176 |
177 | Example:
178 | # 获取 PM 配置信息
179 | config = client.config('pm')
180 |
181 | # 设置IP配置信息
182 | client.config(pm = 2)
183 | """
184 | if kwargs.get('key') is not None: # 修改密码时自动修改 认证模式
185 | if kwargs['key']:
186 | if super().config('security') == 0 and kwargs.get('security') is None:
187 | if len(kwargs['key']) >= 8:
188 | super().config(key=kwargs['key'], security=4)
189 | else:
190 | print('[ERROR] The password length should not be less than 8.')
191 | else:
192 | super().config(security=0)
193 | return super().config(*args, **kwargs)
194 |
195 | @_active
196 | def ifconfig(self, *args, **kwargs) -> tuple:
197 | """
198 | 获取或设置IP级网络接口参数。
199 |
200 | Args:
201 | 包含IP地址、子网掩码、网关和 DNS服务器的元组。默认值为 None,即获取网络状态
202 |
203 | Returns:
204 | tuple: 包含IP地址、子网掩码、网关和 DNS服务器的元组。
205 |
206 | Example:
207 | # 获取IP配置信息
208 | ip_config = client.ifconfig()
209 |
210 | # 设置IP配置信息
211 | client.ifconfig(('192.168.4.1', '255.255.255.0', '192.168.4.1', '0.0.0.0'))
212 | """
213 | return super().ifconfig(*args, **kwargs)
214 |
215 | def isconnected(self) -> bool:
216 | """
217 | 是否有设备接入
218 |
219 | Returns:
220 | True: 有
221 | False: 无
222 | """
223 | return super().isconnected()
224 |
225 | def active(self, *args, **kwargs):
226 | """
227 | 设置 或 获取 WLAN 的活动状态
228 |
229 | Args:
230 | True or False (不填写参数则为获取)
231 |
232 | Returns:
233 | None / bool
234 | """
235 | return super().active(*args, **kwargs)
236 |
--------------------------------------------------------------------------------
/lib/easynetwork-english.py:
--------------------------------------------------------------------------------
1 | import network
2 |
3 |
4 | def _active(func):
5 | """
6 | Check whether WLAN is turned on, if not, turn it on and turn it off
7 | """
8 | def change_active(self, *args, **kwargs):
9 | if not self.active(): # wlan 不常用时尽量减小功耗
10 | self.active(True)
11 | result = func(self, *args, **kwargs)
12 | self.active(False)
13 | return result
14 | else:
15 | return func(self, *args, **kwargs)
16 |
17 | return change_active
18 |
19 |
20 | class Client(network.WLAN):
21 | def __init__(self):
22 | super().__init__(network.STA_IF)
23 | self.PM_NONE = 0
24 | self.PM_PERFORMANCE = 1
25 | self.PM_POWERSAVE = 2
26 |
27 | def connect(self, *args, **kwargs):
28 | """
29 | Connects to a wireless network.
30 |
31 | Args:
32 | ssid: Wireless network name.
33 | key: Wireless network password.
34 | bssid: Connects to a specific device with the specified MAC address if the network name matches (default: None).
35 | reconnects: The number of reconnection attempts (int, 0 for no limit, -1 for unlimited).
36 | """
37 | super().active(True)
38 | super().disconnect()
39 | super().connect(*args, **kwargs)
40 |
41 | @_active
42 | def scan(self) -> list:
43 | """
44 | Scans for wireless networks.
45 |
46 | Returns:
47 | List of tuples containing information about each network:
48 | [(ssid, bssid, channel, RSSI, security, hidden), ...]
49 | """
50 | return super().scan()
51 |
52 | @_active
53 | def config(self, *args, **kwargs):
54 | """
55 | Gets or sets general network interface parameters.
56 |
57 | Args:
58 | pm: Power management setting.
59 | Client.PM_NONE - High-performance mode (0).
60 | Client.PM_PERFORMANCE - Balanced mode (1) (default).
61 | Client.PM_POWERSAVE - Power-saving mode (2).
62 | mac: Wireless network MAC address.
63 | ssid: Wireless network name (read-only).
64 | hostname: Hostname, length should not exceed 32 characters.
65 | tx_power: Maximum transmit power (dBm), generally ranging from 2 to 21.
66 |
67 | Example:
68 | # Get PM configuration info.
69 | config = client.config('pm')
70 |
71 | # Set IP configuration info.
72 | client.config(pm = 2)
73 | """
74 | return super().config(*args, **kwargs)
75 |
76 | def active(self, *args, **kwargs):
77 | """
78 | Sets or gets the active status of WLAN.
79 |
80 | Args:
81 | True or False (default: None for getting the status).
82 |
83 | Returns:
84 | None / bool
85 | """
86 | return super().active(*args, **kwargs)
87 |
88 | def status(self, *args, **kwargs):
89 | """
90 | Gets the network connection status.
91 |
92 | Returns:
93 | network.STAT_IDLE - No connection and no activity (1000)
94 | network.STAT_CONNECTING - Connection in progress (1001)
95 | network.STAT_BEACON_TIMEOUT - Connection failed due to beacon timeout (200)
96 | network.STAT_NO_AP_FOUND - Connection failed due to no access point reply (201)
97 | network.STAT_WRONG_PASSWORD - Connection failed due to wrong password (202)
98 | network.STAT_ASSOC_FAIL - Connection failed due to association problem (203)
99 | network.STAT_HANDSHAKE_TIMEOUT - Connection failed due to handshake timeout (204)
100 | network.STAT_GOT_IP - Connection successful (1010)
101 | """
102 | return super().status(*args, **kwargs)
103 |
104 | def isconnected(self) -> bool:
105 | """
106 | Checks if the network is connected.
107 |
108 | Returns:
109 | True: Connected.
110 | False: Not connected.
111 | """
112 | return super().isconnected()
113 |
114 | def disconnect(self):
115 | """
116 | Disconnects from the network.
117 | """
118 | if super().active():
119 | super().disconnect()
120 | super().active(False) # Disable the Wi-Fi interface.
121 |
122 | @_active
123 | def ifconfig(self, *args, **kwargs) -> tuple:
124 | """
125 | Gets or sets IP-level network interface parameters.
126 |
127 | Args:
128 | A tuple containing the IP address, subnet mask, gateway, and DNS server. The default value is None, which returns the network status.
129 |
130 | Returns:
131 | A tuple containing the IP address, subnet mask, gateway, and DNS server.
132 |
133 | Example:
134 | # Get IP configuration info.
135 | ip_config = client.ifconfig()
136 |
137 | # Set IP configuration info.
138 | client.ifconfig(('192.168.3.4', '255.255.255.0', '192.168.3.1', '8.8.8.8'))
139 | """
140 | return super().ifconfig(*args, **kwargs)
141 |
142 |
143 | class AP(network.WLAN):
144 | def __init__(self):
145 | super().__init__(network.AP_IF)
146 | self.PM_NONE = 0
147 | self.PM_PERFORMANCE = 1
148 | self.PM_POWERSAVE = 2
149 |
150 | @_active
151 | def config(self, *args, **kwargs):
152 | """
153 | Gets or sets general network interface parameters.
154 |
155 | Args:
156 | pm: Power management setting.
157 | AP.PM_NONE - High-performance mode (0).
158 | AP.PM_PERFORMANCE - Balanced mode (1) (default).
159 | AP.PM_POWERSAVE - Power-saving mode (2).
160 | mac: Wireless network MAC address.
161 | key: Connection password. Set the length to be greater than 8 or set it as None, '', or 0. When changing the password, the security is automatically changed as well (write-only, cannot read).
162 | hidden: Whether the network is hidden.
163 | 0 - Visible.
164 | 1 - Hidden.
165 | channel: Channel (generally 1-13).
166 | security: Authentication mode.
167 | 0 - Open.
168 | 1 - WEP.
169 | 2 - WPA-PSK.
170 | 3 - WPA2-PSK.
171 | 4 - WPA/WPA2-PSK.
172 | ssid: Wireless network name.
173 | hostname: Hostname, length should not exceed 32 characters.
174 | tx_power: Maximum transmit power (dBm), generally ranging from 2 to 21.
175 |
176 | Example:
177 | # Get PM configuration info.
178 | config = client.config('pm')
179 |
180 | # Set IP configuration info.
181 | client.config(pm = 2)
182 | """
183 | if kwargs.get('key') is not None:
184 | if kwargs['key']:
185 | if super().config('security') == 0 and kwargs.get('security') is None:
186 | if len(kwargs['key']) >= 8:
187 | super().config(key=kwargs['key'], security=4)
188 | else:
189 | print('[ERROR] The password length should not be less than 8.')
190 | else:
191 | super().config(security=0)
192 | return super().config(*args, **kwargs)
193 |
194 | @_active
195 | def ifconfig(self, *args, **kwargs) -> tuple:
196 | """
197 | Gets or sets IP-level network interface parameters.
198 |
199 | Args:
200 | A tuple containing the IP address, subnet mask, gateway, and DNS server. The default value is None, which returns the network status.
201 |
202 | Returns:
203 | A tuple containing the IP address, subnet mask, gateway, and DNS server.
204 |
205 | Example:
206 | # Get IP configuration info.
207 | ip_config = client.ifconfig()
208 |
209 | # Set IP configuration info.
210 | client.ifconfig(('192.168.4.1', '255.255.255.0', '192.168.4.1', '0.0.0.0'))
211 | """
212 | return super().ifconfig(*args, **kwargs)
213 |
214 | def isconnected(self) -> bool:
215 | """
216 | Checks if any device is connected.
217 |
218 | Returns:
219 | True: Device(s) connected.
220 | False: No device connected.
221 | """
222 | return super().isconnected()
223 |
224 | def active(self, *args, **kwargs):
225 | """
226 | Sets or gets the active status of WLAN.
227 |
228 | Args:
229 | True or False (default: None for getting the status).
230 |
231 | Returns:
232 | None / bool
233 | """
234 | return super().active(*args, **kwargs)
235 |
--------------------------------------------------------------------------------
/lib/easyweb_single.py:
--------------------------------------------------------------------------------
1 | # Github: https://github.com/funnygeeker/micropython-easyweb
2 | # Author: funnygeeker
3 | # Licence: MIT
4 | # Date: 2023/11/23
5 | #
6 | # 参考项目:
7 | # https://github.com/maysrp/micropython_webconfig
8 | #
9 | # 参考资料:
10 | # https://blog.csdn.net/wapecheng/article/details/93522153
11 | # https://blog.csdn.net/qq_42482078/article/details/131514743
12 | # https://blog.csdn.net/weixin_41665106/article/details/105599235
13 | import os
14 | import socket
15 | import binascii
16 | import ujson as json
17 |
18 | # 文件类型对照
19 | FILE_TYPE = {
20 | "txt": "text/plain",
21 | "htm": "text/html",
22 | "html": "text/html",
23 | "css": "text/css",
24 | "csv": "text/csv",
25 | "js": "application/javascript",
26 | "xml": "application/xml",
27 | "xhtml": "application/xhtml+xml",
28 | "json": "application/json",
29 | "zip": "application/zip",
30 | "pdf": "application/pdf",
31 | "ts": "application/typescript",
32 | "woff": "font/woff",
33 | "woff2": "font/woff2",
34 | "ttf": "font/ttf",
35 | "otf": "font/otf",
36 | "jpg": "image/jpeg",
37 | "jpeg": "image/jpeg",
38 | "png": "image/png",
39 | "gif": "image/gif",
40 | "svg": "image/svg+xml",
41 | "ico": "image/x-icon"
42 | } # 其他: "application/octet-stream"
43 |
44 |
45 | def exists(path):
46 | """文件是否存在"""
47 | try:
48 | os.stat(path)
49 | return True
50 | except:
51 | print('[ERROR] EasyWeb: File Not Exists - {}'.format(path))
52 | return False
53 |
54 |
55 | def url_encode(url):
56 | """URL 编码"""
57 | encoded_url = ''
58 | for char in url:
59 | if char.isalpha() or char.isdigit() or char in ('-', '.', '_', '~'):
60 | encoded_url += char
61 | else:
62 | encoded_url += '%' + binascii.hexlify(char.encode('utf-8')).decode('utf-8').upper()
63 | return encoded_url
64 |
65 |
66 | def url_decode(encoded_url):
67 | """
68 | URL 解码
69 | """
70 | encoded_url = encoded_url.replace('+', ' ')
71 | # 如果 URL 中不包含'%', 则表示已解码,直接返回原始 URL
72 | if '%' not in encoded_url:
73 | return encoded_url
74 | blocks = encoded_url.split('%') # 以 '%' 分割 URL
75 | decoded_url = blocks[0] # 初始化解码后的 URL
76 | buffer = "" # 初始化缓冲区
77 |
78 | for b in blocks[1:]:
79 | if len(b) == 2:
80 | buffer += b[:2] # 如果是两位的十六进制数,加入缓冲区
81 | else: # 解码相应的十六进制数并将其解码的字符加入解码后的 URL 中
82 | decoded_url += binascii.unhexlify(buffer + b[:2]).decode('utf-8')
83 | buffer = "" # 清空缓冲区
84 | decoded_url += b[2:] # 将剩余部分直接加入解码后的 URL 中
85 | # 处理缓冲区尾部剩余的十六进制数
86 | if buffer:
87 | decoded_url += binascii.unhexlify(buffer).decode('utf-8')
88 | return decoded_url # 返回解码后的 URL
89 |
90 |
91 | class _Response:
92 | """
93 | 表示 HTTP 响应的类
94 | """
95 | STATUS_CODE = {
96 | 100: "Continue",
97 | 101: "Switching Protocols",
98 | 102: "Processing",
99 | 200: "OK",
100 | 201: "Created",
101 | 202: "Accepted",
102 | 203: "Non-Authoritative Information",
103 | 204: "No Content",
104 | 205: "Reset Content",
105 | 206: "Partial Content",
106 | 207: "Multi-Status",
107 | 208: "Already Reported",
108 | 226: "IM Used",
109 | 300: "Multiple Choices",
110 | 301: "Moved Permanently",
111 | 302: "Found",
112 | 303: "See Other",
113 | 304: "Not Modified",
114 | 305: "Use Proxy",
115 | 307: "Temporary Redirect",
116 | 308: "Permanent Redirect",
117 | 400: "Bad Request",
118 | 401: "Unauthorized",
119 | 402: "Payment Required",
120 | 403: "Forbidden",
121 | 404: "Not Found",
122 | 405: "Method Not Allowed",
123 | 406: "Not Acceptable",
124 | 407: "Proxy Authentication Required",
125 | 408: "Request Timeout",
126 | 409: "Conflict",
127 | 410: "Gone",
128 | 411: "Length Required",
129 | 412: "Precondition Failed",
130 | 413: "Payload Too Large",
131 | 414: "URI Too Long",
132 | 415: "Unsupported Media Type",
133 | 416: "Range Not Satisfiable",
134 | 417: "Expectation Failed",
135 | 418: "I'm a teapot",
136 | 422: "Unprocessable Entity",
137 | 423: "Locked",
138 | 424: "Failed Dependency",
139 | 426: "Upgrade Required",
140 | 428: "Precondition Required",
141 | 429: "Too Many Requests",
142 | 431: "Request Header Fields Too Large",
143 | 451: "Unavailable For Legal Reasons",
144 | 500: "Internal Server Error",
145 | 501: "Not Implemented",
146 | 502: "Bad Gateway",
147 | 503: "Service Unavailable",
148 | 504: "Gateway Timeout",
149 | 505: "HTTP Version Not Supported",
150 | 506: "Variant Also Negotiates",
151 | 507: "Insufficient Storage",
152 | 508: "Loop Detected",
153 | 510: "Not Extended",
154 | 511: "Network Authentication Required"
155 | }
156 |
157 | def __init__(self):
158 | self.status_code = 200
159 | 'HTTP 状态码'
160 | self.status = None
161 | 'HTTP 状态文本'
162 | self.headers = {}
163 | self.cookies = {}
164 | self.charset = 'utf-8'
165 | self.data = b''
166 |
167 | def set_data(self, data):
168 | """
169 | 设置响应体数据
170 |
171 | Args:
172 | data: 数据,可以为 str, bytes, generator[bytes]
173 | """
174 | if isinstance(data, str):
175 | self.data = data.encode()
176 |
177 | def set_cookie(self, name: str, value: str = '', max_age: int = None):
178 | """
179 | 构造包含提供的 cookies 和 max_age(可选)的 Set-Cookie 响应头
180 |
181 | 参数:
182 | cookies: 包含 Cookie 键值对的字典。
183 | max_age: Cookie 最大有效期,以秒为单位。默认 None
184 |
185 | 返回:
186 | bytes: Set-Cookie 响应头
187 | """
188 | if max_age is None:
189 | max_age = ""
190 | else:
191 | max_age = "; Max-Age={}".format(max_age)
192 | self.cookies[name] = "Set-Cookie: {}={}{}".format(url_encode(name), url_encode(value), max_age)
193 |
194 | @staticmethod
195 | def _generator():
196 | """一个生成器"""
197 | yield 0
198 |
199 | def is_generator(self, obj):
200 | """判断对象是否为生成器"""
201 | return isinstance(obj, type(self._generator()))
202 |
203 | def _get_status(self):
204 | """获取状态码"""
205 | if self.status is not None:
206 | return self.status
207 | else:
208 | return "{} {}".format(self.status_code, self.STATUS_CODE.get(self.status_code, "NULL"))
209 |
210 | def _get_cookies(self):
211 | """获取 Cookies"""
212 | c = ""
213 | for k, v in self.cookies.items():
214 | c += v
215 | c += "\r\n"
216 | return c
217 |
218 | def get_response(self):
219 | """
220 | 获取完整的 HTTP 响应生成器
221 |
222 | Returns:
223 | 包含响应内容的生成器
224 | """
225 | status = self._get_status()
226 |
227 | if isinstance(self.data, str): # 处理数据
228 | self.data = self.data.encode()
229 | self.headers['Content-Type'] = 'text/html'
230 | elif isinstance(self.data, dict):
231 | self.data = json.dumps(self.data).encode()
232 | self.headers['Content-Type'] = 'application/json'
233 | else:
234 | self.data = self.data
235 |
236 | if isinstance(status, bytes): # 响应头
237 | yield b"HTTP/1.1 " + status + b"\r\n"
238 | else:
239 | yield "HTTP/1.1 {}\r\n".format(status).encode()
240 | if isinstance(self.data, bytes):
241 | yield ("\r\n".join([f"{k}: {v}" for k, v in self.headers.items()]) + "\r\n").encode()
242 | yield (self._get_cookies() + "\r\n").encode()
243 | if self.data:
244 | yield self.data
245 | elif self.is_generator(self.data):
246 | i = True
247 | for d in self.data:
248 | if i: # 只执行一次
249 | i = False
250 | if type(d) == dict:
251 | self.headers.update(d)
252 | yield ("\r\n".join([f"{k}: {v}" for k, v in self.headers.items()]) + "\r\n").encode()
253 | yield (self._get_cookies() + "\r\n").encode()
254 | if type(d) != dict:
255 | yield d
256 | else:
257 | yield d
258 | else:
259 | print("[WARN] EasyWeb: Unsupported data type.")
260 |
261 |
262 | class _Request:
263 | """
264 | 表示 HTTP 请求的类
265 |
266 | Attributes:
267 | path (str): 请求路径
268 | data (bytes): 请求体
269 | method (str): 请求方法,例如 GET / POST
270 | headers (dict): 请求头
271 | protocol (str): HTTP 协议版本
272 | full_path (str): 完整路径
273 |
274 | Properties:
275 | url (str / None): 获取请求中的 URL
276 | json (dict / None): 解析请求中的 JSON
277 | host (str / None): 获取请求中的 Host
278 | args (dict / None): 解析请求中的参数
279 | form (dict / None): 解析请求中的表单
280 |
281 | Note:
282 | 在解析数据时,如果出现异常,则返回 None。
283 | """
284 |
285 | def __init__(self):
286 | self._url = None
287 | self._args = None
288 | self._form = None
289 | self._json = None
290 | self._cookies = {}
291 | self.path: str = ''
292 | '请求路径'
293 | self.data: bytes = b''
294 | '请求体'
295 | self.method: str = ""
296 | '请求方法,例如 GET, POST'
297 | self.headers: dict = {}
298 | '请求头 (字典)'
299 | self.protocol: str = ""
300 | 'HTTP 协议版本'
301 | self.full_path: str = ""
302 | '完整路径'
303 | self.match: str = None
304 | '匹配的结果'
305 |
306 | @property
307 | def url(self):
308 | """
309 | 获取请求中的 URL
310 |
311 | Returns:
312 | str / None: 当成功解析 URL 时返回 URL 字符串,否则返回 None(程序出错)
313 |
314 | Examples:
315 | request.url
316 | """
317 | if self._url is None:
318 | host = self.host
319 | if host:
320 | self._url = "http://{}{}".format(host, self.full_path)
321 | return self._url
322 |
323 | @property
324 | def json(self):
325 | """
326 | 解析请求中的 JSON
327 |
328 | Returns:
329 | dict / None: 当成功解析 JSON 时返回字典,否则返回 None(程序出错)
330 |
331 | Examples:
332 | request.json
333 | """
334 | if self._json is None:
335 | try:
336 | self._json = json.loads(self.data)
337 | except:
338 | pass
339 | return self._json
340 |
341 | @property
342 | def host(self):
343 | """
344 | 获取请求中的 Host
345 |
346 | Returns:
347 | str / None: 当成功解析 Host 时返回 Host 字符串,否则返回 None
348 |
349 | Examples:
350 | request.host
351 | """
352 | return self.headers.get('Host')
353 |
354 | @property
355 | def args(self):
356 | """
357 | 解析请求中的参数
358 |
359 | Returns:
360 | dict / None: 当成功解析参数时返回参数字典,否则返回 None(无法解析时或程序出错)
361 |
362 | Examples:
363 | request.args
364 | """
365 | if self._args is None:
366 | try:
367 | # 提取问号后面的部分
368 | query_str = self.full_path.split('?', 1)[-1].rstrip("&")
369 | if query_str:
370 | args_list = query_str.split('&') # 分割参数键值对
371 | args = {}
372 | # 解析参数键值对
373 | for arg_pair in args_list:
374 | key, value = arg_pair.split('=')
375 | key = url_decode(key)
376 | value = url_decode(value)
377 | args[key] = value
378 | self._args = args # 缓存结果
379 | except:
380 | pass
381 | return self._args
382 |
383 | @property
384 | def form(self):
385 | """
386 | 解析请求中的表单数据
387 |
388 | Returns:
389 | dict / None: 当成功解析表单数据时返回表单数据字典,否则返回 None(无法解析时或程序出错)
390 |
391 | Examples:
392 | request.form
393 | """
394 | if self._form is None:
395 | try:
396 | items = self.data.decode("utf-8").split("&")
397 | form = {}
398 | for _ in items:
399 | k, v = _.split("=", 1)
400 | if not v:
401 | v = None
402 | else:
403 | v = url_decode(v)
404 | form[k] = v
405 | except:
406 | return None
407 | self._form = form # 缓存结果
408 | return self._form
409 |
410 | @property
411 | def cookies(self):
412 | """
413 | 解析请求中的 Cookie 数据
414 |
415 | Returns:
416 | dict / None: 当成功解析 Cookies 数据时返回 Cookies 数据字典,否则返回 None(无法解析时或程序出错)
417 |
418 | Examples:
419 | request.cookies
420 | """
421 | if not self._cookies:
422 | try:
423 | cookies = {}
424 | items = self.headers.get('Cookie').split(";")
425 | for item in items:
426 | item = item.strip()
427 | if '=' in item:
428 | k, v = item.split('=', 1)
429 | k = url_decode(k)
430 | v = url_decode(v)
431 | cookies[k] = v
432 | except:
433 | return {}
434 | self._cookies = cookies # 缓存结果
435 | return self._cookies
436 |
437 |
438 | class _HttpError(Exception):
439 | """
440 | 表示 HTTP 错误的异常类。
441 | """
442 | pass
443 |
444 |
445 | class EasyWeb:
446 | # HTTP 响应代码
447 | CODE_200 = b"HTTP/1.1 200 OK\r\n"
448 | CODE_404 = b"HTTP/1.1 404 Not Found\r\nContent-Type: text/html\r\n\r\nError 404: Page not found.
"
449 | CODE_405 = b"HTTP/1.1 405 Method Not Allowed\r\nContent-Type: text/html\r\n\r\nError 405: Method not allowed.
"
450 |
451 | def __init__(self):
452 | self.host = str
453 | '监听的 IP'
454 | self.port = int
455 | '监听的端口'
456 | self.routes = []
457 | '路由表'
458 | self.server = None
459 | '服务器实例'
460 |
461 | def route(self, path: str, methods: list = None):
462 | """
463 | 用于添加路由处理的装饰器
464 |
465 | Args:
466 | path: 用于匹配请求路径的字符串
467 | methods: 允许的请求方法列表,默认为 ['POST', 'GET']
468 |
469 | Example:
470 | @app.route("/")
471 | def index(request):
472 | return "Hello, World!"
473 |
474 | Notes:
475 | 另外支持使用 "/" 和 ”/“ 对末尾的字符串或者路径进行匹配,可以通过 request.match 获取匹配的结果
476 | """
477 | # 添加路由装饰器
478 | if methods is None:
479 | methods = ['POST', 'GET']
480 |
481 | def decorator(func):
482 | self.routes.append((path, func, methods))
483 | return func
484 |
485 | return decorator
486 |
487 | def run(self, host="0.0.0.0", port=80):
488 | """
489 | 运行 Web 服务器
490 | 创建并启动循环,在其中运行服务器,监听客户端请求。
491 |
492 | Note:
493 | 当调用此方法后,服务器将运行并阻塞事件循环,直到使用 `Ctrl+C` 或者 `stop()` 等方式进行终止。
494 | """
495 | self.host = host
496 | '监听的 IP'
497 | self.port = port
498 | '监听的端口'
499 | self.server = True
500 | # 创建套接字并监听连接
501 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
502 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
503 | s.bind((self.host, self.port))
504 | s.listen(5)
505 | # 循环处理连接
506 | while self.server:
507 | conn, addr = s.accept()
508 | self.handle(conn)
509 |
510 | def handle(self, conn):
511 | """
512 | 处理客户端的请求并生成对应的响应。
513 |
514 | Args:
515 | conn: 用于从客户端读取请求数据和发送响应的对象
516 | """
517 | request = _Request()
518 | conn.settimeout(5)
519 | try:
520 | raw = conn.readline() # HTTP 请求方法,路径,协议版本
521 | raw = raw.decode('utf-8').split(" ")
522 | if len(raw) != 3:
523 | return
524 | raw[2] = raw[2].rstrip("\r\n")
525 | request.method, request.full_path, request.protocol = raw
526 | request.path = request.full_path.split('?', 1)[0]
527 | # 协议版本检查
528 | if request.protocol not in ("HTTP/1.0", "HTTP/1.1"):
529 | raise _HttpError(request.protocol, 505, "Version Not Supported")
530 | # 解析 HTTP 请求头
531 | while True:
532 | raw = conn.readline() # 请求头参数:\r\n
533 | raw = raw.decode('utf-8').rstrip('\r\n').split(": ", 1)
534 | if len(raw) == 2:
535 | k, v = raw
536 | request.headers[k] = v
537 | elif len(raw) == 1: # 请求头结束:\r\n\r\n
538 | break
539 | else:
540 | pass
541 | # 查找匹配路由
542 | for route_path, route_func, route_methods in self.routes:
543 | # 匹配路由 "/" 和 "/" "/"
544 | # 完全匹配
545 | if route_path.rstrip("/") == request.path.rstrip("/"):
546 | request.match = None
547 | match_success = True
548 | # 匹配字符串
549 | elif route_path[-9:] == "/" and route_path[:-9] == "/".join(
550 | request.path.rstrip("/").split("/")[:-1]):
551 | request.match = request.path[len(route_path[:-9]) + 1:]
552 | match_success = True
553 | # 匹配路径
554 | elif (route_path[-7:] == "/" and
555 | "/".join(request.path.rstrip("/").split("/")[:-1]).startswith(route_path[:-7]) and
556 | request.path[len(route_path[:-7]) + 1:]):
557 | request.match = request.path[len(route_path[:-7]) + 1:]
558 | match_success = True
559 | else:
560 | match_success = False
561 | if match_success: # 完全匹配,字符串匹配,路径匹配
562 | if request.method in route_methods: # 匹配到路由
563 | # 获取请求体
564 | size = int(request.headers.get("Content-Length", 0))
565 | if size:
566 | request.data = conn.read(size)
567 | else:
568 | request.data = None
569 | # 调用路由处理函数并发送响应
570 | response = route_func(request) # str / bytes / generator / None
571 | try:
572 | response.get_response
573 | except AttributeError:
574 | if isinstance(response, tuple): # return response, status_code, headers
575 | try:
576 | response[0].get_response
577 | status_code = response[1]
578 | if len(response) == 3:
579 | headers = response[2]
580 | else:
581 | headers = None
582 | response = response[0]
583 | response.status_code = status_code
584 | if headers:
585 | response.headers.update(headers)
586 | except AttributeError:
587 | if len(response) == 2:
588 | response = make_response(response[0], response[1])
589 | elif len(response) == 3:
590 | response = make_response(response[0], response[1], response[2])
591 | else: # return bytes / str / iterables / tuple (bytes / str / iterables, status_code, headersr)
592 | response = make_response(response)
593 |
594 | for res in response.get_response():
595 | conn.sendall(res)
596 | else:
597 | # 发送"方法不允许"响应
598 | conn.sendall(self.CODE_405)
599 | break
600 | else:
601 | # 发送"页面不存在"响应
602 | conn.sendall(self.CODE_404)
603 | except OSError:
604 | pass
605 | except Exception as e:
606 | print("[WARN] EasyWEB: {}".format(e))
607 | finally:
608 | # 关闭连接
609 | conn.close()
610 |
611 | def stop(self):
612 | """
613 | 停止运行 EasyWeb Server
614 | """
615 | if self.server:
616 | self.server = None
617 |
618 |
619 | def send_file(file, mimetype: str = None, as_attachment=False, attachment_filename=None):
620 | """
621 | 发送文件给客户端
622 |
623 | Args:
624 | file: 要发送的文件的路径
625 | mimetype: 文件的 MIME 类型。如果未指定,EasyWeb 将尝试根据文件扩展名进行猜测
626 | as_attachment: 是否作为附件发送文件,作为附件时会被下载
627 | attachment_filename: 下载文件时向用户显示的文件名。如果未提供,将使用原始文件名
628 |
629 | Returns:
630 | 包含 HTTP 200 OK 和 文件的二进制数据 的可迭代对象
631 | """
632 | head = {'Content-Type': 'application/octet-stream'}
633 | if as_attachment: # 作为附件发送文件
634 | if not attachment_filename: # 下载文件时的文件名
635 | attachment_filename = file.split("/")[-1]
636 | head['Content-Disposition'] = 'attachment; filename="{}"'.format(attachment_filename)
637 | else:
638 | if mimetype is None: # 自动识别文件的 MIME 类型
639 | e = file.split(".")
640 | if len(e) >= 2:
641 | head['Content-Type'] = FILE_TYPE.get(e[-1], "application/octet-stream")
642 | if not exists(file):
643 | yield {'Content-Type': 'text/html'}
644 | yield 'File Not Exists: {}
'.format(file).encode("utf-8")
645 | else:
646 | yield head
647 | with open(file, "rb") as f:
648 | _file = True
649 | while _file:
650 | _file = f.read(1024)
651 | yield _file
652 |
653 |
654 | def render_template(file, **kwargs):
655 | """
656 | 渲染模板
657 |
658 | Args:
659 | file: 要渲染的 html 模板路径
660 | **kwargs: 传递给函数的其他关键字参数,将在模板中用于渲染
661 |
662 | Returns:
663 | 包含 HTTP 200 OK 和渲染后的 HTML 内容字符串 的可迭代对象
664 | """
665 | yield {'Content-Type': 'text/html'}
666 | if not exists(file):
667 | yield 'File Not Exists: {}
'.format(file).encode("utf-8")
668 | else:
669 | with open(file, "r") as f:
670 | _file = True
671 | f_readline = f.readline
672 | while _file:
673 | _file = f_readline() + f_readline() + f_readline() + f_readline() + f_readline() # 五行一组进行渲染
674 | for k, v in kwargs.items(): # 渲染参数
675 | _file = _file.replace("{{{{{}}}}}".format(k), str(v))
676 | yield _file.encode("utf-8")
677 |
678 |
679 | def make_response(content=b'', status_code: int = 200, headers=None) -> _Response:
680 | """
681 | 创建一个带有 内容、状态码 和 头部 的 响应对象。
682 |
683 | Args:
684 | content: 响应的内容,可以为 Iterable (bytes),str,tuple,dict
685 | status_code (int): 响应的状态码,默认为 200。
686 | headers: 可选的头部,包含在响应中,默认为 None。
687 |
688 | Returns:
689 | _Response: 响应对象
690 | """
691 | if headers is None:
692 | headers = {}
693 | response = _Response()
694 | if isinstance(content, tuple):
695 | if len(content) >= 2:
696 | content, status_code = content[:2]
697 | if len(content) == 3:
698 | headers = content[2]
699 | response.headers = headers
700 | response.status_code = status_code
701 | response.data = content
702 | return response
703 |
--------------------------------------------------------------------------------
/lib/easyweb_thread.py:
--------------------------------------------------------------------------------
1 | # Github: https://github.com/funnygeeker/micropython-easyweb
2 | # Author: funnygeeker
3 | # Licence: MIT
4 | # Date: 2023/11/23
5 | #
6 | # 参考项目:
7 | # https://github.com/maysrp/micropython_webconfig
8 | #
9 | # 参考资料:
10 | # https://blog.csdn.net/wapecheng/article/details/93522153
11 | # https://blog.csdn.net/qq_42482078/article/details/131514743
12 | # https://blog.csdn.net/weixin_41665106/article/details/105599235
13 | import os
14 | import socket
15 | import _thread
16 | import binascii
17 | import ujson as json
18 |
19 | # 文件类型对照
20 | FILE_TYPE = {
21 | "txt": "text/plain",
22 | "htm": "text/html",
23 | "html": "text/html",
24 | "css": "text/css",
25 | "csv": "text/csv",
26 | "js": "application/javascript",
27 | "xml": "application/xml",
28 | "xhtml": "application/xhtml+xml",
29 | "json": "application/json",
30 | "zip": "application/zip",
31 | "pdf": "application/pdf",
32 | "ts": "application/typescript",
33 | "woff": "font/woff",
34 | "woff2": "font/woff2",
35 | "ttf": "font/ttf",
36 | "otf": "font/otf",
37 | "jpg": "image/jpeg",
38 | "jpeg": "image/jpeg",
39 | "png": "image/png",
40 | "gif": "image/gif",
41 | "svg": "image/svg+xml",
42 | "ico": "image/x-icon"
43 | } # 其他: "application/octet-stream"
44 |
45 |
46 | def exists(path):
47 | """文件是否存在"""
48 | try:
49 | os.stat(path)
50 | return True
51 | except:
52 | print('[ERROR] EasyWeb: File Not Exists - {}'.format(path))
53 | return False
54 |
55 |
56 | def url_encode(url):
57 | """URL 编码"""
58 | encoded_url = ''
59 | for char in url:
60 | if char.isalpha() or char.isdigit() or char in ('-', '.', '_', '~'):
61 | encoded_url += char
62 | else:
63 | encoded_url += '%' + binascii.hexlify(char.encode('utf-8')).decode('utf-8').upper()
64 | return encoded_url
65 |
66 |
67 | def url_decode(encoded_url):
68 | """
69 | URL 解码
70 | """
71 | encoded_url = encoded_url.replace('+', ' ')
72 | # 如果 URL 中不包含'%', 则表示已解码,直接返回原始 URL
73 | if '%' not in encoded_url:
74 | return encoded_url
75 | blocks = encoded_url.split('%') # 以 '%' 分割 URL
76 | decoded_url = blocks[0] # 初始化解码后的 URL
77 | buffer = "" # 初始化缓冲区
78 |
79 | for b in blocks[1:]:
80 | if len(b) == 2:
81 | buffer += b[:2] # 如果是两位的十六进制数,加入缓冲区
82 | else: # 解码相应的十六进制数并将其解码的字符加入解码后的 URL 中
83 | decoded_url += binascii.unhexlify(buffer + b[:2]).decode('utf-8')
84 | buffer = "" # 清空缓冲区
85 | decoded_url += b[2:] # 将剩余部分直接加入解码后的 URL 中
86 | # 处理缓冲区尾部剩余的十六进制数
87 | if buffer:
88 | decoded_url += binascii.unhexlify(buffer).decode('utf-8')
89 | return decoded_url # 返回解码后的 URL
90 |
91 |
92 | class _Response:
93 | """
94 | 表示 HTTP 响应的类
95 | """
96 | STATUS_CODE = {
97 | 100: "Continue",
98 | 101: "Switching Protocols",
99 | 102: "Processing",
100 | 200: "OK",
101 | 201: "Created",
102 | 202: "Accepted",
103 | 203: "Non-Authoritative Information",
104 | 204: "No Content",
105 | 205: "Reset Content",
106 | 206: "Partial Content",
107 | 207: "Multi-Status",
108 | 208: "Already Reported",
109 | 226: "IM Used",
110 | 300: "Multiple Choices",
111 | 301: "Moved Permanently",
112 | 302: "Found",
113 | 303: "See Other",
114 | 304: "Not Modified",
115 | 305: "Use Proxy",
116 | 307: "Temporary Redirect",
117 | 308: "Permanent Redirect",
118 | 400: "Bad Request",
119 | 401: "Unauthorized",
120 | 402: "Payment Required",
121 | 403: "Forbidden",
122 | 404: "Not Found",
123 | 405: "Method Not Allowed",
124 | 406: "Not Acceptable",
125 | 407: "Proxy Authentication Required",
126 | 408: "Request Timeout",
127 | 409: "Conflict",
128 | 410: "Gone",
129 | 411: "Length Required",
130 | 412: "Precondition Failed",
131 | 413: "Payload Too Large",
132 | 414: "URI Too Long",
133 | 415: "Unsupported Media Type",
134 | 416: "Range Not Satisfiable",
135 | 417: "Expectation Failed",
136 | 418: "I'm a teapot",
137 | 422: "Unprocessable Entity",
138 | 423: "Locked",
139 | 424: "Failed Dependency",
140 | 426: "Upgrade Required",
141 | 428: "Precondition Required",
142 | 429: "Too Many Requests",
143 | 431: "Request Header Fields Too Large",
144 | 451: "Unavailable For Legal Reasons",
145 | 500: "Internal Server Error",
146 | 501: "Not Implemented",
147 | 502: "Bad Gateway",
148 | 503: "Service Unavailable",
149 | 504: "Gateway Timeout",
150 | 505: "HTTP Version Not Supported",
151 | 506: "Variant Also Negotiates",
152 | 507: "Insufficient Storage",
153 | 508: "Loop Detected",
154 | 510: "Not Extended",
155 | 511: "Network Authentication Required"
156 | }
157 |
158 | def __init__(self):
159 | self.status_code = 200
160 | 'HTTP 状态码'
161 | self.status = None
162 | 'HTTP 状态文本'
163 | self.headers = {}
164 | self.cookies = {}
165 | self.charset = 'utf-8'
166 | self.data = b''
167 |
168 | def set_data(self, data):
169 | """
170 | 设置响应体数据
171 |
172 | Args:
173 | data: 数据,可以为 str, bytes, generator[bytes]
174 | """
175 | if isinstance(data, str):
176 | self.data = data.encode()
177 |
178 | def set_cookie(self, name: str, value: str = '', max_age: int = None):
179 | """
180 | 构造包含提供的 cookies 和 max_age(可选)的 Set-Cookie 响应头
181 |
182 | 参数:
183 | cookies: 包含 Cookie 键值对的字典。
184 | max_age: Cookie 最大有效期,以秒为单位。默认 None
185 |
186 | 返回:
187 | bytes: Set-Cookie 响应头
188 | """
189 | if max_age is None:
190 | max_age = ""
191 | else:
192 | max_age = "; Max-Age={}".format(max_age)
193 | self.cookies[name] = "Set-Cookie: {}={}{}".format(url_encode(name), url_encode(value), max_age)
194 |
195 | @staticmethod
196 | def _generator():
197 | """一个生成器"""
198 | yield 0
199 |
200 | def is_generator(self, obj):
201 | """判断对象是否为生成器"""
202 | return isinstance(obj, type(self._generator()))
203 |
204 | def _get_status(self):
205 | """获取状态码"""
206 | if self.status is not None:
207 | return self.status
208 | else:
209 | return "{} {}".format(self.status_code, self.STATUS_CODE.get(self.status_code, "NULL"))
210 |
211 | def _get_cookies(self):
212 | """获取 Cookies"""
213 | c = ""
214 | for k, v in self.cookies.items():
215 | c += v
216 | c += "\r\n"
217 | return c
218 |
219 | def get_response(self):
220 | """
221 | 获取完整的 HTTP 响应生成器
222 |
223 | Returns:
224 | 包含响应内容的生成器
225 | """
226 | status = self._get_status()
227 |
228 | if isinstance(self.data, str): # 处理数据
229 | self.data = self.data.encode()
230 | self.headers['Content-Type'] = 'text/html'
231 | elif isinstance(self.data, dict):
232 | self.data = json.dumps(self.data).encode()
233 | self.headers['Content-Type'] = 'application/json'
234 | else:
235 | self.data = self.data
236 |
237 | if isinstance(status, bytes): # 响应头
238 | yield b"HTTP/1.1 " + status + b"\r\n"
239 | else:
240 | yield "HTTP/1.1 {}\r\n".format(status).encode()
241 | if isinstance(self.data, bytes):
242 | yield ("\r\n".join([f"{k}: {v}" for k, v in self.headers.items()]) + "\r\n").encode()
243 | yield (self._get_cookies() + "\r\n").encode()
244 | if self.data:
245 | yield self.data
246 | elif self.is_generator(self.data):
247 | i = True
248 | for d in self.data:
249 | if i: # 只执行一次
250 | i = False
251 | if type(d) == dict:
252 | self.headers.update(d)
253 | yield ("\r\n".join([f"{k}: {v}" for k, v in self.headers.items()]) + "\r\n").encode()
254 | yield (self._get_cookies() + "\r\n").encode()
255 | if type(d) != dict:
256 | yield d
257 | else:
258 | yield d
259 | else:
260 | print("[WARN] EasyWeb: Unsupported data type.")
261 |
262 |
263 | class _Request:
264 | """
265 | 表示 HTTP 请求的类
266 |
267 | Attributes:
268 | path (str): 请求路径
269 | data (bytes): 请求体
270 | method (str): 请求方法,例如 GET / POST
271 | headers (dict): 请求头
272 | protocol (str): HTTP 协议版本
273 | full_path (str): 完整路径
274 |
275 | Properties:
276 | url (str / None): 获取请求中的 URL
277 | json (dict / None): 解析请求中的 JSON
278 | host (str / None): 获取请求中的 Host
279 | args (dict / None): 解析请求中的参数
280 | form (dict / None): 解析请求中的表单
281 |
282 | Note:
283 | 在解析数据时,如果出现异常,则返回 None。
284 | """
285 |
286 | def __init__(self):
287 | self._url = None
288 | self._args = None
289 | self._form = None
290 | self._json = None
291 | self._cookies = {}
292 | self.path: str = ''
293 | '请求路径'
294 | self.data: bytes = b''
295 | '请求体'
296 | self.method: str = ""
297 | '请求方法,例如 GET, POST'
298 | self.headers: dict = {}
299 | '请求头 (字典)'
300 | self.protocol: str = ""
301 | 'HTTP 协议版本'
302 | self.full_path: str = ""
303 | '完整路径'
304 | self.match: str = None
305 | '匹配的结果'
306 |
307 | @property
308 | def url(self):
309 | """
310 | 获取请求中的 URL
311 |
312 | Returns:
313 | str / None: 当成功解析 URL 时返回 URL 字符串,否则返回 None(程序出错)
314 |
315 | Examples:
316 | request.url
317 | """
318 | if self._url is None:
319 | host = self.host
320 | if host:
321 | self._url = "http://{}{}".format(host, self.full_path)
322 | return self._url
323 |
324 | @property
325 | def json(self):
326 | """
327 | 解析请求中的 JSON
328 |
329 | Returns:
330 | dict / None: 当成功解析 JSON 时返回字典,否则返回 None(程序出错)
331 |
332 | Examples:
333 | request.json
334 | """
335 | if self._json is None:
336 | try:
337 | self._json = json.loads(self.data)
338 | except:
339 | pass
340 | return self._json
341 |
342 | @property
343 | def host(self):
344 | """
345 | 获取请求中的 Host
346 |
347 | Returns:
348 | str / None: 当成功解析 Host 时返回 Host 字符串,否则返回 None
349 |
350 | Examples:
351 | request.host
352 | """
353 | return self.headers.get('Host')
354 |
355 | @property
356 | def args(self):
357 | """
358 | 解析请求中的参数
359 |
360 | Returns:
361 | dict / None: 当成功解析参数时返回参数字典,否则返回 None(无法解析时或程序出错)
362 |
363 | Examples:
364 | request.args
365 | """
366 | if self._args is None:
367 | try:
368 | # 提取问号后面的部分
369 | query_str = self.full_path.split('?', 1)[-1].rstrip("&")
370 | if query_str:
371 | args_list = query_str.split('&') # 分割参数键值对
372 | args = {}
373 | # 解析参数键值对
374 | for arg_pair in args_list:
375 | key, value = arg_pair.split('=')
376 | key = url_decode(key)
377 | value = url_decode(value)
378 | args[key] = value
379 | self._args = args # 缓存结果
380 | except:
381 | pass
382 | return self._args
383 |
384 | @property
385 | def form(self):
386 | """
387 | 解析请求中的表单数据
388 |
389 | Returns:
390 | dict / None: 当成功解析表单数据时返回表单数据字典,否则返回 None(无法解析时或程序出错)
391 |
392 | Examples:
393 | request.form
394 | """
395 | if self._form is None:
396 | try:
397 | items = self.data.decode("utf-8").split("&")
398 | form = {}
399 | for _ in items:
400 | k, v = _.split("=", 1)
401 | if not v:
402 | v = None
403 | else:
404 | v = url_decode(v)
405 | form[k] = v
406 | except:
407 | return None
408 | self._form = form # 缓存结果
409 | return self._form
410 |
411 | @property
412 | def cookies(self):
413 | """
414 | 解析请求中的 Cookie 数据
415 |
416 | Returns:
417 | dict / None: 当成功解析 Cookies 数据时返回 Cookies 数据字典,否则返回 None(无法解析时或程序出错)
418 |
419 | Examples:
420 | request.cookies
421 | """
422 | if not self._cookies:
423 | try:
424 | cookies = {}
425 | items = self.headers.get('Cookie').split(";")
426 | for item in items:
427 | item = item.strip()
428 | if '=' in item:
429 | k, v = item.split('=', 1)
430 | k = url_decode(k)
431 | v = url_decode(v)
432 | cookies[k] = v
433 | except:
434 | return {}
435 | self._cookies = cookies # 缓存结果
436 | return self._cookies
437 |
438 |
439 | class _HttpError(Exception):
440 | """
441 | 表示 HTTP 错误的异常类。
442 | """
443 | pass
444 |
445 |
446 | class EasyWeb:
447 | # HTTP 响应代码
448 | CODE_200 = b"HTTP/1.1 200 OK\r\n"
449 | CODE_404 = b"HTTP/1.1 404 Not Found\r\nContent-Type: text/html\r\n\r\nError 404: Page not found.
"
450 | CODE_405 = b"HTTP/1.1 405 Method Not Allowed\r\nContent-Type: text/html\r\n\r\nError 405: Method not allowed.
"
451 |
452 | def __init__(self):
453 | self.host = str
454 | '监听的 IP'
455 | self.port = int
456 | '监听的端口'
457 | self.routes = []
458 | '路由表'
459 | self.server = None
460 | '服务器实例'
461 |
462 | def route(self, path: str, methods: list = None):
463 | """
464 | 用于添加路由处理的装饰器
465 |
466 | Args:
467 | path: 用于匹配请求路径的字符串
468 | methods: 允许的请求方法列表,默认为 ['POST', 'GET']
469 |
470 | Example:
471 | @app.route("/")
472 | def index(request):
473 | return "Hello, World!"
474 |
475 | Notes:
476 | 另外支持使用 "/" 和 ”/“ 对末尾的字符串或者路径进行匹配,可以通过 request.match 获取匹配的结果
477 | """
478 | # 添加路由装饰器
479 | if methods is None:
480 | methods = ['POST', 'GET']
481 |
482 | def decorator(func):
483 | self.routes.append((path, func, methods))
484 | return func
485 |
486 | return decorator
487 |
488 | def run(self, host="0.0.0.0", port=80):
489 | """
490 | 运行 Web 服务器
491 | 创建并启动循环,在其中运行服务器,监听客户端请求。
492 |
493 | Note:
494 | 当调用此方法后,服务器将运行并阻塞事件循环,直到使用 `Ctrl+C` 或者 `stop()` 等方式进行终止。
495 | """
496 | self.host = host
497 | '监听的 IP'
498 | self.port = port
499 | '监听的端口'
500 | self.server = True
501 | # 创建套接字并监听连接
502 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
503 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
504 | s.bind((self.host, self.port))
505 | s.listen(5)
506 | # 循环处理连接
507 | while self.server:
508 | conn, addr = s.accept()
509 | _thread.start_new_thread(self.handle, (conn,))
510 |
511 | def handle(self, conn):
512 | """
513 | 处理客户端的请求并生成对应的响应。
514 |
515 | Args:
516 | conn: 用于从客户端读取请求数据和发送响应的对象
517 | """
518 | request = _Request()
519 | conn.settimeout(5)
520 | try:
521 | raw = conn.readline() # HTTP 请求方法,路径,协议版本
522 | raw = raw.decode('utf-8').split(" ")
523 | if len(raw) != 3:
524 | return
525 | raw[2] = raw[2].rstrip("\r\n")
526 | request.method, request.full_path, request.protocol = raw
527 | request.path = request.full_path.split('?', 1)[0]
528 | # 协议版本检查
529 | if request.protocol not in ("HTTP/1.0", "HTTP/1.1"):
530 | raise _HttpError(request.protocol, 505, "Version Not Supported")
531 | # 解析 HTTP 请求头
532 | while True:
533 | raw = conn.readline() # 请求头参数:\r\n
534 | raw = raw.decode('utf-8').rstrip('\r\n').split(": ", 1)
535 | if len(raw) == 2:
536 | k, v = raw
537 | request.headers[k] = v
538 | elif len(raw) == 1: # 请求头结束:\r\n\r\n
539 | break
540 | else:
541 | pass
542 | # 查找匹配路由
543 | for route_path, route_func, route_methods in self.routes:
544 | # 匹配路由 "/" 和 "/" "/"
545 | # 完全匹配
546 | if route_path.rstrip("/") == request.path.rstrip("/"):
547 | request.match = None
548 | match_success = True
549 | # 匹配字符串
550 | elif route_path[-9:] == "/" and route_path[:-9] == "/".join(
551 | request.path.rstrip("/").split("/")[:-1]):
552 | request.match = request.path[len(route_path[:-9]) + 1:]
553 | match_success = True
554 | # 匹配路径
555 | elif (route_path[-7:] == "/" and
556 | "/".join(request.path.rstrip("/").split("/")[:-1]).startswith(route_path[:-7]) and
557 | request.path[len(route_path[:-7]) + 1:]):
558 | request.match = request.path[len(route_path[:-7]) + 1:]
559 | match_success = True
560 | else:
561 | match_success = False
562 | if match_success: # 完全匹配,字符串匹配,路径匹配
563 | if request.method in route_methods: # 匹配到路由
564 | # 获取请求体
565 | size = int(request.headers.get("Content-Length", 0))
566 | if size:
567 | request.data = conn.read(size)
568 | else:
569 | request.data = None
570 | # 调用路由处理函数并发送响应
571 | response = route_func(request) # str / bytes / generator / None
572 | try:
573 | response.get_response
574 | except AttributeError:
575 | if isinstance(response, tuple): # return response, status_code, headers
576 | try:
577 | response[0].get_response
578 | status_code = response[1]
579 | if len(response) == 3:
580 | headers = response[2]
581 | else:
582 | headers = None
583 | response = response[0]
584 | response.status_code = status_code
585 | if headers:
586 | response.headers.update(headers)
587 | except AttributeError:
588 | if len(response) == 2:
589 | response = make_response(response[0], response[1])
590 | elif len(response) == 3:
591 | response = make_response(response[0], response[1], response[2])
592 | else: # return bytes / str / iterables / tuple (bytes / str / iterables, status_code, headersr)
593 | response = make_response(response)
594 |
595 | for res in response.get_response():
596 | conn.sendall(res)
597 | else:
598 | # 发送"方法不允许"响应
599 | conn.sendall(self.CODE_405)
600 | break
601 | else:
602 | # 发送"页面不存在"响应
603 | conn.sendall(self.CODE_404)
604 | except OSError:
605 | pass
606 | except Exception as e:
607 | print("[WARN] EasyWEB: {}".format(e))
608 | finally:
609 | # 关闭连接
610 | conn.close()
611 |
612 | def stop(self):
613 | """
614 | 停止运行 EasyWeb Server
615 | """
616 | if self.server:
617 | self.server = None
618 | _thread.exit()
619 |
620 |
621 | def send_file(file, mimetype: str = None, as_attachment=False, attachment_filename=None):
622 | """
623 | 发送文件给客户端
624 |
625 | Args:
626 | file: 要发送的文件的路径
627 | mimetype: 文件的 MIME 类型。如果未指定,EasyWeb 将尝试根据文件扩展名进行猜测
628 | as_attachment: 是否作为附件发送文件,作为附件时会被下载
629 | attachment_filename: 下载文件时向用户显示的文件名。如果未提供,将使用原始文件名
630 |
631 | Returns:
632 | 包含 HTTP 200 OK 和 文件的二进制数据 的可迭代对象
633 | """
634 | head = {'Content-Type': 'application/octet-stream'}
635 | if as_attachment: # 作为附件发送文件
636 | if not attachment_filename: # 下载文件时的文件名
637 | attachment_filename = file.split("/")[-1]
638 | head['Content-Disposition'] = 'attachment; filename="{}"'.format(attachment_filename)
639 | else:
640 | if mimetype is None: # 自动识别文件的 MIME 类型
641 | e = file.split(".")
642 | if len(e) >= 2:
643 | head['Content-Type'] = FILE_TYPE.get(e[-1], "application/octet-stream")
644 | if not exists(file):
645 | yield {'Content-Type': 'text/html'}
646 | yield 'File Not Exists: {}
'.format(file).encode("utf-8")
647 | else:
648 | yield head
649 | with open(file, "rb") as f:
650 | _file = True
651 | while _file:
652 | _file = f.read(1024)
653 | yield _file
654 |
655 |
656 | def render_template(file, **kwargs):
657 | """
658 | 渲染模板
659 |
660 | Args:
661 | file: 要渲染的 html 模板路径
662 | **kwargs: 传递给函数的其他关键字参数,将在模板中用于渲染
663 |
664 | Returns:
665 | 包含 HTTP 200 OK 和渲染后的 HTML 内容字符串 的可迭代对象
666 | """
667 | yield {'Content-Type': 'text/html'}
668 | if not exists(file):
669 | yield 'File Not Exists: {}
'.format(file).encode("utf-8")
670 | else:
671 | with open(file, "r") as f:
672 | _file = True
673 | f_readline = f.readline
674 | while _file:
675 | _file = f_readline() + f_readline() + f_readline() + f_readline() + f_readline() # 五行一组进行渲染
676 | for k, v in kwargs.items(): # 渲染参数
677 | _file = _file.replace("{{{{{}}}}}".format(k), str(v))
678 | yield _file.encode("utf-8")
679 |
680 |
681 | def make_response(content=b'', status_code: int = 200, headers=None) -> _Response:
682 | """
683 | 创建一个带有 内容、状态码 和 头部 的 响应对象。
684 |
685 | Args:
686 | content: 响应的内容,可以为 Iterable (bytes),str,tuple,dict
687 | status_code (int): 响应的状态码,默认为 200。
688 | headers: 可选的头部,包含在响应中,默认为 None。
689 |
690 | Returns:
691 | _Response: 响应对象
692 | """
693 | if headers is None:
694 | headers = {}
695 | response = _Response()
696 | if isinstance(content, tuple):
697 | if len(content) >= 2:
698 | content, status_code = content[:2]
699 | if len(content) == 3:
700 | headers = content[2]
701 | response.headers = headers
702 | response.status_code = status_code
703 | response.data = content
704 | return response
705 |
--------------------------------------------------------------------------------
/lib/easyweb.py:
--------------------------------------------------------------------------------
1 | # Github: https://github.com/funnygeeker/micropython-easyweb
2 | # Author: funnygeeker
3 | # Licence: MIT
4 | # Date: 2023/11/23
5 | #
6 | # 参考项目:
7 | # https://github.com/maysrp/micropython_webconfig
8 | #
9 | # 参考资料:
10 | # https://flask.palletsprojects.com/en/3.0.x/quickstart/
11 | # https://blog.csdn.net/wapecheng/article/details/93522153
12 | # https://blog.csdn.net/qq_42482078/article/details/131514743
13 | # https://blog.csdn.net/weixin_41665106/article/details/105599235
14 | import os
15 | import binascii
16 | import ujson as json
17 | import uasyncio as asyncio
18 |
19 | # 文件类型对照
20 | FILE_TYPE = {
21 | "txt": "text/plain",
22 | "htm": "text/html",
23 | "html": "text/html",
24 | "css": "text/css",
25 | "csv": "text/csv",
26 | "js": "application/javascript",
27 | "xml": "application/xml",
28 | "xhtml": "application/xhtml+xml",
29 | "json": "application/json",
30 | "zip": "application/zip",
31 | "pdf": "application/pdf",
32 | "ts": "application/typescript",
33 | "woff": "font/woff",
34 | "woff2": "font/woff2",
35 | "ttf": "font/ttf",
36 | "otf": "font/otf",
37 | "jpg": "image/jpeg",
38 | "jpeg": "image/jpeg",
39 | "png": "image/png",
40 | "gif": "image/gif",
41 | "svg": "image/svg+xml",
42 | "ico": "image/x-icon"
43 | } # 其他: "application/octet-stream"
44 |
45 |
46 | def exists(path):
47 | """文件是否存在"""
48 | try:
49 | os.stat(path)
50 | return True
51 | except:
52 | print('[ERROR] EasyWeb: File Not Exists - {}'.format(path))
53 | return False
54 |
55 |
56 | def url_encode(url):
57 | """URL 编码"""
58 | encoded_url = ''
59 | for char in url:
60 | if char.isalpha() or char.isdigit() or char in ('-', '.', '_', '~'):
61 | encoded_url += char
62 | else:
63 | encoded_url += '%' + binascii.hexlify(char.encode('utf-8')).decode('utf-8').upper()
64 | return encoded_url
65 |
66 |
67 | def url_decode(encoded_url):
68 | """
69 | URL 解码
70 | """
71 | encoded_url = encoded_url.replace('+', ' ')
72 | # 如果 URL 中不包含'%', 则表示已解码,直接返回原始 URL
73 | if '%' not in encoded_url:
74 | return encoded_url
75 | blocks = encoded_url.split('%') # 以 '%' 分割 URL
76 | decoded_url = blocks[0] # 初始化解码后的 URL
77 | buffer = "" # 初始化缓冲区
78 |
79 | for b in blocks[1:]:
80 | if len(b) == 2:
81 | buffer += b[:2] # 如果是两位的十六进制数,加入缓冲区
82 | else: # 解码相应的十六进制数并将其解码的字符加入解码后的 URL 中
83 | decoded_url += binascii.unhexlify(buffer + b[:2]).decode('utf-8')
84 | buffer = "" # 清空缓冲区
85 | decoded_url += b[2:] # 将剩余部分直接加入解码后的 URL 中
86 | # 处理缓冲区尾部剩余的十六进制数
87 | if buffer:
88 | decoded_url += binascii.unhexlify(buffer).decode('utf-8')
89 | return decoded_url # 返回解码后的 URL
90 |
91 |
92 | class _Response:
93 | """
94 | 表示 HTTP 响应的类
95 | """
96 | STATUS_CODE = {
97 | 100: "Continue",
98 | 101: "Switching Protocols",
99 | 102: "Processing",
100 | 200: "OK",
101 | 201: "Created",
102 | 202: "Accepted",
103 | 203: "Non-Authoritative Information",
104 | 204: "No Content",
105 | 205: "Reset Content",
106 | 206: "Partial Content",
107 | 207: "Multi-Status",
108 | 208: "Already Reported",
109 | 226: "IM Used",
110 | 300: "Multiple Choices",
111 | 301: "Moved Permanently",
112 | 302: "Found",
113 | 303: "See Other",
114 | 304: "Not Modified",
115 | 305: "Use Proxy",
116 | 307: "Temporary Redirect",
117 | 308: "Permanent Redirect",
118 | 400: "Bad Request",
119 | 401: "Unauthorized",
120 | 402: "Payment Required",
121 | 403: "Forbidden",
122 | 404: "Not Found",
123 | 405: "Method Not Allowed",
124 | 406: "Not Acceptable",
125 | 407: "Proxy Authentication Required",
126 | 408: "Request Timeout",
127 | 409: "Conflict",
128 | 410: "Gone",
129 | 411: "Length Required",
130 | 412: "Precondition Failed",
131 | 413: "Payload Too Large",
132 | 414: "URI Too Long",
133 | 415: "Unsupported Media Type",
134 | 416: "Range Not Satisfiable",
135 | 417: "Expectation Failed",
136 | 418: "I'm a teapot",
137 | 422: "Unprocessable Entity",
138 | 423: "Locked",
139 | 424: "Failed Dependency",
140 | 426: "Upgrade Required",
141 | 428: "Precondition Required",
142 | 429: "Too Many Requests",
143 | 431: "Request Header Fields Too Large",
144 | 451: "Unavailable For Legal Reasons",
145 | 500: "Internal Server Error",
146 | 501: "Not Implemented",
147 | 502: "Bad Gateway",
148 | 503: "Service Unavailable",
149 | 504: "Gateway Timeout",
150 | 505: "HTTP Version Not Supported",
151 | 506: "Variant Also Negotiates",
152 | 507: "Insufficient Storage",
153 | 508: "Loop Detected",
154 | 510: "Not Extended",
155 | 511: "Network Authentication Required"
156 | }
157 |
158 | def __init__(self):
159 | self.status_code = 200
160 | 'HTTP 状态码'
161 | self.status = None
162 | 'HTTP 状态文本'
163 | self.headers = {}
164 | self.cookies = {}
165 | self.charset = 'utf-8'
166 | self.data = b''
167 |
168 | def set_data(self, data):
169 | """
170 | 设置响应体数据
171 |
172 | Args:
173 | data: 数据,可以为 str, bytes, generator[bytes]
174 | """
175 | if isinstance(data, str):
176 | self.data = data.encode()
177 |
178 | def set_cookie(self, name: str, value: str = '', max_age: int = None):
179 | """
180 | 构造包含提供的 cookies 和 max_age(可选)的 Set-Cookie 响应头
181 |
182 | 参数:
183 | cookies: 包含 Cookie 键值对的字典。
184 | max_age: Cookie 最大有效期,以秒为单位。默认 None
185 |
186 | 返回:
187 | bytes: Set-Cookie 响应头
188 | """
189 | if max_age is None:
190 | max_age = ""
191 | else:
192 | max_age = "; Max-Age={}".format(max_age)
193 | self.cookies[name] = "Set-Cookie: {}={}{}".format(url_encode(name), url_encode(value), max_age)
194 |
195 | @staticmethod
196 | def _generator():
197 | """一个生成器"""
198 | yield 0
199 |
200 | def is_generator(self, obj):
201 | """判断对象是否为生成器"""
202 | return isinstance(obj, type(self._generator()))
203 |
204 | def _get_status(self):
205 | """获取状态码"""
206 | if self.status is not None:
207 | return self.status
208 | else:
209 | return "{} {}".format(self.status_code, self.STATUS_CODE.get(self.status_code, "NULL"))
210 |
211 | def _get_cookies(self):
212 | """获取 Cookies"""
213 | c = ""
214 | for k, v in self.cookies.items():
215 | c += v
216 | c += "\r\n"
217 | return c
218 |
219 | def get_response(self):
220 | """
221 | 获取完整的 HTTP 响应生成器
222 |
223 | Returns:
224 | 包含响应内容的生成器
225 | """
226 | status = self._get_status()
227 |
228 | if isinstance(self.data, str): # 处理数据
229 | self.data = self.data.encode()
230 | self.headers['Content-Type'] = 'text/html'
231 | elif isinstance(self.data, dict):
232 | self.data = json.dumps(self.data).encode()
233 | self.headers['Content-Type'] = 'application/json'
234 | else:
235 | self.data = self.data
236 |
237 | if isinstance(status, bytes): # 响应头
238 | yield b"HTTP/1.1 " + status + b"\r\n"
239 | else:
240 | yield "HTTP/1.1 {}\r\n".format(status).encode()
241 | if isinstance(self.data, bytes):
242 | yield ("\r\n".join([f"{k}: {v}" for k, v in self.headers.items()]) + "\r\n").encode()
243 | yield (self._get_cookies() + "\r\n").encode()
244 | if self.data:
245 | yield self.data
246 | elif self.is_generator(self.data):
247 | i = True
248 | for d in self.data:
249 | if i: # 只执行一次
250 | i = False
251 | if type(d) == dict:
252 | self.headers.update(d)
253 | yield ("\r\n".join([f"{k}: {v}" for k, v in self.headers.items()]) + "\r\n").encode()
254 | yield (self._get_cookies() + "\r\n").encode()
255 | if type(d) != dict:
256 | yield d
257 | else:
258 | yield d
259 | else:
260 | print("[WARN] EasyWeb: Unsupported data type.")
261 |
262 |
263 | class _Request:
264 | """
265 | 表示 HTTP 请求的类
266 |
267 | Attributes:
268 | path (str): 请求路径
269 | data (bytes): 请求体
270 | method (str): 请求方法,例如 GET / POST
271 | headers (dict): 请求头
272 | protocol (str): HTTP 协议版本
273 | full_path (str): 完整路径
274 |
275 | Properties:
276 | url (str / None): 获取请求中的 URL
277 | json (dict / None): 解析请求中的 JSON
278 | host (str / None): 获取请求中的 Host
279 | args (dict / None): 解析请求中的参数
280 | form (dict / None): 解析请求中的表单
281 |
282 | Note:
283 | 在解析数据时,如果出现异常,则返回 None。
284 | """
285 |
286 | def __init__(self):
287 | self._url = None
288 | self._args = None
289 | self._form = None
290 | self._json = None
291 | self._cookies = {}
292 | self.path: str = ''
293 | '请求路径'
294 | self.data: bytes = b''
295 | '请求体'
296 | self.method: str = ""
297 | '请求方法,例如 GET, POST'
298 | self.headers: dict = {}
299 | '请求头 (字典)'
300 | self.protocol: str = ""
301 | 'HTTP 协议版本'
302 | self.full_path: str = ""
303 | '完整路径'
304 | self.match: str = None
305 | '匹配的结果'
306 |
307 | @property
308 | def url(self):
309 | """
310 | 获取请求中的 URL
311 |
312 | Returns:
313 | str / None: 当成功解析 URL 时返回 URL 字符串,否则返回 None(程序出错)
314 |
315 | Examples:
316 | request.url
317 | """
318 | if self._url is None:
319 | host = self.host
320 | if host:
321 | self._url = "http://{}{}".format(host, self.full_path)
322 | return self._url
323 |
324 | @property
325 | def json(self):
326 | """
327 | 解析请求中的 JSON
328 |
329 | Returns:
330 | dict / None: 当成功解析 JSON 时返回字典,否则返回 None(程序出错)
331 |
332 | Examples:
333 | request.json
334 | """
335 | if self._json is None:
336 | try:
337 | self._json = json.loads(self.data)
338 | except:
339 | pass
340 | return self._json
341 |
342 | @property
343 | def host(self):
344 | """
345 | 获取请求中的 Host
346 |
347 | Returns:
348 | str / None: 当成功解析 Host 时返回 Host 字符串,否则返回 None
349 |
350 | Examples:
351 | request.host
352 | """
353 | return self.headers.get('Host')
354 |
355 | @property
356 | def args(self):
357 | """
358 | 解析请求中的参数
359 |
360 | Returns:
361 | dict / None: 当成功解析参数时返回参数字典,否则返回 None(无法解析时或程序出错)
362 |
363 | Examples:
364 | request.args
365 | """
366 | if self._args is None:
367 | try:
368 | # 提取问号后面的部分
369 | query_str = self.full_path.split('?', 1)[-1].rstrip("&")
370 | if query_str:
371 | args_list = query_str.split('&') # 分割参数键值对
372 | args = {}
373 | # 解析参数键值对
374 | for arg_pair in args_list:
375 | key, value = arg_pair.split('=')
376 | key = url_decode(key)
377 | value = url_decode(value)
378 | args[key] = value
379 | self._args = args # 缓存结果
380 | except:
381 | pass
382 | return self._args
383 |
384 | @property
385 | def form(self):
386 | """
387 | 解析请求中的表单数据
388 |
389 | Returns:
390 | dict / None: 当成功解析表单数据时返回表单数据字典,否则返回 None(无法解析时或程序出错)
391 |
392 | Examples:
393 | request.form
394 | """
395 | if self._form is None:
396 | try:
397 | items = self.data.decode("utf-8").split("&")
398 | form = {}
399 | for _ in items:
400 | k, v = _.split("=", 1)
401 | if not v:
402 | v = None
403 | else:
404 | v = url_decode(v)
405 | form[k] = v
406 | except:
407 | return None
408 | self._form = form # 缓存结果
409 | return self._form
410 |
411 | @property
412 | def cookies(self):
413 | """
414 | 解析请求中的 Cookie 数据
415 |
416 | Returns:
417 | dict / None: 当成功解析 Cookies 数据时返回 Cookies 数据字典,否则返回 None(无法解析时或程序出错)
418 |
419 | Examples:
420 | request.cookies
421 | """
422 | if not self._cookies:
423 | try:
424 | cookies = {}
425 | items = self.headers.get('Cookie').split(";")
426 | for item in items:
427 | item = item.strip()
428 | if '=' in item:
429 | k, v = item.split('=', 1)
430 | k = url_decode(k)
431 | v = url_decode(v)
432 | cookies[k] = v
433 | except:
434 | return {}
435 | self._cookies = cookies # 缓存结果
436 | return self._cookies
437 |
438 |
439 | class _HttpError(Exception):
440 | """
441 | 表示 HTTP 错误的异常类。
442 | """
443 | pass
444 |
445 |
446 | class EasyWeb:
447 | # HTTP 响应代码
448 | CODE_200 = b"HTTP/1.1 200 OK\r\n"
449 | CODE_404 = b"HTTP/1.1 404 Not Found\r\nContent-Type: text/html\r\n\r\nError 404: Page not found.
"
450 | CODE_405 = b"HTTP/1.1 405 Method Not Allowed\r\nContent-Type: text/html\r\n\r\nError 405: Method not allowed.
"
451 |
452 | def __init__(self):
453 | self.host = str
454 | '监听的 IP'
455 | self.port = int
456 | '监听的端口'
457 | self.routes = []
458 | '路由表'
459 | self.server = None
460 | '服务器实例'
461 |
462 | def route(self, path: str, methods: list = None):
463 | """
464 | 用于添加路由处理的装饰器
465 |
466 | Args:
467 | path: 用于匹配请求路径的字符串
468 | methods: 允许的请求方法列表,默认为 ['POST', 'GET']
469 |
470 | Example:
471 | @app.route("/")
472 | def index(request):
473 | return "Hello, World!"
474 |
475 | Notes:
476 | 另外支持使用 "/" 和 ”/“ 对末尾的字符串或者路径进行匹配,可以通过 request.match 获取匹配的结果
477 | """
478 | # 添加路由装饰器
479 | if methods is None:
480 | methods = ['POST', 'GET']
481 |
482 | def decorator(func):
483 | self.routes.append((path, func, methods))
484 | return func
485 |
486 | return decorator
487 |
488 | async def raw_run(self):
489 | return await asyncio.start_server(self.handle, self.host, self.port)
490 |
491 | def run(self, host="0.0.0.0", port=80):
492 | """
493 | 运行 Web 服务器
494 | 创建并启动事件循环,在其中运行服务器,监听客户端请求。
495 |
496 | Note:
497 | 当调用此方法后,服务器将运行并阻塞事件循环,直到使用 `Ctrl+C` 或者 `stop()` 等方式进行终止。
498 | """
499 | self.host = host
500 | '监听的 IP'
501 | self.port = port
502 | '监听的端口'
503 | # asyncio 获取事件循环
504 | self.server = asyncio.get_event_loop()
505 | self.server.create_task(self.raw_run())
506 | self.server.run_forever()
507 |
508 | async def handle(self, reader, writer):
509 | """
510 | 处理客户端的请求并生成对应的响应。
511 |
512 | Args:
513 | reader: 用于从客户端读取请求数据的流。
514 | writer: 用于向客户端发送响应数据的流。
515 | """
516 | request = _Request()
517 | try:
518 | raw = await reader.readline() # HTTP 请求方法,路径,协议版本
519 | raw = raw.decode('utf-8').split(" ")
520 | if len(raw) != 3:
521 | return
522 | raw[2] = raw[2].rstrip("\r\n")
523 | request.method, request.full_path, request.protocol = raw
524 | request.path = request.full_path.split('?', 1)[0]
525 | # 协议版本检查
526 | if request.protocol not in ("HTTP/1.0", "HTTP/1.1"):
527 | raise _HttpError(request.protocol, 505, "Version Not Supported")
528 | # 解析 HTTP 请求头
529 | while True:
530 | raw = await reader.readline() # 请求头参数:\r\n
531 | raw = raw.decode('utf-8').rstrip('\r\n').split(": ", 1)
532 | if len(raw) == 2:
533 | k, v = raw
534 | request.headers[k] = v
535 | elif len(raw) == 1: # 请求头结束:\r\n\r\n
536 | break
537 | else:
538 | pass
539 | # 查找匹配路由
540 | for route_path, route_func, route_methods in self.routes:
541 | # 匹配路由 "/" 和 "/" "/"
542 | # 完全匹配
543 | if route_path.rstrip("/") == request.path.rstrip("/"):
544 | request.match = None
545 | match_success = True
546 | # 匹配字符串
547 | elif route_path[-9:] == "/" and route_path[:-9] == "/".join(
548 | request.path.rstrip("/").split("/")[:-1]):
549 | request.match = request.path[len(route_path[:-9]) + 1:]
550 | match_success = True
551 | # 匹配路径
552 | elif (route_path[-7:] == "/" and
553 | "/".join(request.path.rstrip("/").split("/")[:-1]).startswith(route_path[:-7]) and
554 | request.path[len(route_path[:-7]) + 1:]):
555 | request.match = request.path[len(route_path[:-7]) + 1:]
556 | match_success = True
557 | else:
558 | match_success = False
559 | if match_success: # 完全匹配,字符串匹配,路径匹配
560 | if request.method in route_methods: # 匹配到路由
561 | # 获取请求体
562 | size = int(request.headers.get("Content-Length", 0))
563 | if size:
564 | try:
565 | request.data = await asyncio.wait_for(reader.read(size), timeout=5)
566 | except asyncio.TimeoutError as e:
567 | raise OSError('[ERROR] Asyncio TimeoutError: {}'.format(e))
568 | else:
569 | request.data = None
570 | # 调用路由处理函数并发送响应
571 | response = route_func(request) # str / bytes / generator / None
572 | try:
573 | response.get_response
574 | except AttributeError:
575 | if isinstance(response, tuple): # return response, status_code, headers
576 | try:
577 | response[0].get_response
578 | status_code = response[1]
579 | if len(response) == 3:
580 | headers = response[2]
581 | else:
582 | headers = None
583 | response = response[0]
584 | response.status_code = status_code
585 | if headers:
586 | response.headers.update(headers)
587 | except AttributeError:
588 | if len(response) == 2:
589 | response = make_response(response[0], response[1])
590 | elif len(response) == 3:
591 | response = make_response(response[0], response[1], response[2])
592 | else: # return bytes / str / iterables / tuple (bytes / str / iterables, status_code, headersr)
593 | response = make_response(response)
594 |
595 | for res in response.get_response():
596 | await writer.awrite(res)
597 | else:
598 | # 发送"方法不允许"响应
599 | await writer.awrite(self.CODE_405)
600 | break
601 | else:
602 | # 发送"页面不存在"响应
603 | await writer.awrite(self.CODE_404)
604 | except Exception as e:
605 | print("[WARN] EasyWEB: {}".format(e))
606 | finally:
607 | # 关闭连接
608 | await writer.aclose()
609 |
610 | def stop(self):
611 | """
612 | 停止运行 EasyWeb Server
613 | """
614 | if self.server:
615 | self.server.stop()
616 | self.server = None
617 |
618 |
619 | def send_file(file, mimetype: str = None, as_attachment=False, attachment_filename=None):
620 | """
621 | 发送文件给客户端
622 |
623 | Args:
624 | file: 要发送的文件的路径
625 | mimetype: 文件的 MIME 类型。如果未指定,EasyWeb 将尝试根据文件扩展名进行猜测
626 | as_attachment: 是否作为附件发送文件,作为附件时会被下载
627 | attachment_filename: 下载文件时向用户显示的文件名。如果未提供,将使用原始文件名
628 |
629 | Returns:
630 | 包含 HTTP 200 OK 和 文件的二进制数据 的可迭代对象
631 | """
632 | head = {'Content-Type': 'application/octet-stream'}
633 | if as_attachment: # 作为附件发送文件
634 | if not attachment_filename: # 下载文件时的文件名
635 | attachment_filename = file.split("/")[-1]
636 | head['Content-Disposition'] = 'attachment; filename="{}"'.format(attachment_filename)
637 | else:
638 | if mimetype is None: # 自动识别文件的 MIME 类型
639 | e = file.split(".")
640 | if len(e) >= 2:
641 | head['Content-Type'] = FILE_TYPE.get(e[-1], "application/octet-stream")
642 | if not exists(file):
643 | yield {'Content-Type': 'text/html'}
644 | yield 'File Not Exists: {}
'.format(file).encode("utf-8")
645 | else:
646 | yield head
647 | with open(file, "rb") as f:
648 | _file = True
649 | while _file:
650 | _file = f.read(1024)
651 | yield _file
652 |
653 |
654 | def render_template(file, **kwargs):
655 | """
656 | 渲染模板
657 |
658 | Args:
659 | file: 要渲染的 html 模板路径
660 | **kwargs: 传递给函数的其他关键字参数,将在模板中用于渲染
661 |
662 | Returns:
663 | 包含 HTTP 200 OK 和渲染后的 HTML 内容字符串 的可迭代对象
664 | """
665 | yield {'Content-Type': 'text/html'}
666 | if not exists(file):
667 | yield 'File Not Exists: {}
'.format(file).encode("utf-8")
668 | else:
669 | with open(file, "r") as f:
670 | _file = True
671 | f_readline = f.readline
672 | while _file:
673 | _file = f_readline() + f_readline() + f_readline() + f_readline() + f_readline() # 五行一组进行渲染
674 | for k, v in kwargs.items(): # 渲染参数
675 | _file = _file.replace("{{{{{}}}}}".format(k), str(v))
676 | yield _file.encode("utf-8")
677 |
678 |
679 | def make_response(content=b'', status_code: int = 200, headers=None) -> _Response:
680 | """
681 | 创建一个带有 内容、状态码 和 头部 的 响应对象。
682 |
683 | Args:
684 | content: 响应的内容,可以为 Iterable (bytes),str,tuple,dict
685 | status_code (int): 响应的状态码,默认为 200。
686 | headers: 可选的头部,包含在响应中,默认为 None。
687 |
688 | Returns:
689 | _Response: 响应对象
690 | """
691 | if headers is None:
692 | headers = {}
693 | response = _Response()
694 | if isinstance(content, tuple):
695 | if len(content) >= 2:
696 | content, status_code = content[:2]
697 | if len(content) == 3:
698 | headers = content[2]
699 | response.headers = headers
700 | response.status_code = status_code
701 | response.data = content
702 | return response
703 |
--------------------------------------------------------------------------------