本文主要内容搬运自本人CSDN以下博文,欢迎大家关注!
前言
在研发测试日常工作中,通常会遇到很多琐碎的事情,占用我们工作的时间和精力,从而导致我们不能把大部分的注意力放在主要的工作上面。为了解决这个问题,除了加人之外,我们通常会开发一些日常用的效率工具,比如以pyqt、pyside为主体的桌面应用,一键化我们的日常工作,从而解放我们很多处理琐碎事情的精力,让我们有更多精力打磨主业,创造更好的工作成绩。
因此,本文就分享下笔者在24年下半年调研学习pyside6的一些成果,把自己做的小应用homemade-toolset开源出来,并且把技术实现的过程也呈现出来,供各位有需要的同学参考学习,如何用pyside6开发一个python桌面工具。
整个项目包含时钟工具、JSON工具以及类似Postman的HTTP请求工具,采用python3.11和pyside6开发。接下来看一下,整个项目具体怎么搭建起来。
初始化项目
初始化项目并不难,先强调一点是,所有的资料都可以在官网查到。如果有特别疑问的地方,参考官网,实在不行就stackoverflow或者gpt,也许可以更快解决问题。
首先是折腾项目工作区。从个人开发角度,笔者推荐所有的桌面开发项目都放在一个pyside6的工作区,并采用venv来安装pyside6相关库和工具。
pyside6的工具有很多,比如把ui文件转化为python代码的pyside6-uic,以及编辑ui的可视化工具pyside6-designer之类。如果是venv安pyside6的话,这些工具都集成到了${project_dir}/.venv/bin
下面,有需要的话也可以export到path里,具体作用详细可以参考官网的这份资料。通过这些工具加上一些脚本,就能简单打通ui编辑->ui转码->代码编写->部署发布的开发链路(p.s. 部署发布相关的调研暂时不多)。
代码组织方面,推荐先是把工具类、业务逻辑和ui逻辑几个模块分离开,然后重要一点是,把ui生成代码和实际的window跟widget类给分开来,做到view和model的区分。这样一来是大小层次比较分明,不会出现循环引用的情况,二来是从ui生成的代码,也不会直接影响到已有代码的实现,做改动也是非常方便。在homemade-toolset项目中,最终呈现的代码结构是:
- app:应用内容
- init.py:app初始化逻辑
- service:业务逻辑
- util:工具类
- view:前端逻辑
- component:可复用的ui组件
- ui:通过pyside6-designer生成的ui代码
- worker:后台异步/并发的任务类
- XXX.py:主页面逻辑
- cfg:配置文件
- etc:静态资源
- main.py:程序入口
- pysidedeploy.spec
- README.md:项目介绍
以这样的项目结构,通过app文件夹就可以存储所有业务逻辑,最外层的main.py就能够驱动app运行:
python
import app
if __name__ == '__main__':
app.run()
然后在app的__init__.py启动整个项目
python
APP: Optional[QtWidgets.QApplication] = None
def run():
global APP
APP = QtWidgets.QApplication([])
window = MainWindow()
window.ensure_center()
window.show()
sys.exit(APP.exec())
最后在每个ui类实现里面来初始化跟定义界面逻辑:
python
class MainWindow(QMainWindow):
def __init__(self):
super(MainWindow, self).__init__()
self.ui = Ui_MainWindow()
self.ui.setupUi(self)
self._init_actions()
self._init_widget()
def _init_actions(self):
self.ui.actionAbout.triggered.connect(self.show_about)
self.ui.actionExit.triggered.connect(self.close)
self.ui.actionSupport.triggered.connect(self.show_support)
def _init_widget(self):
# 主动set中心widget,后续可以通过配置化方式灵活设置不同的界面
self.setCentralWidget(ToolWidget())
def closeEvent(self, event): # 关闭窗口时触发
reply = QMessageBox.question(
self, '确认', '是否要退出程序?',
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes)
if reply == QMessageBox.StandardButton.Yes:
event.accept()
else:
event.ignore()
界面设计
界面设计上,用一个TabWidget就可以简单做个门户入口,区分多个子功能的界面,每个子功能也可以单独在一个ui界面去设计。
每一个Widget里面需要定义组件的排版(layout),在designer里面,一般是Widget包含了一个组件才能够编辑layout。layout有很多种,垂直(vertical)的话类似于web前端里面一个个Row的排列,水平(horizontal)的话则类似于一个个Col的排列,栅格(Grid)布局的话每个组件的占用面积可能和旁边组件相关联,还有一种表单(Form)布局则专门用于填写表单配置类,是label+input的阻塞。
在layout限制下,每个组件可能占据一块区域,但组件自己也有一些填充策略。像PushButton,一般水平或垂直策略需要调整成fixed,保证不填充到整个区域,要按照自己本身的大小来填,而比如Spacer,需要做占位把组件分割成左右两块的,默认会设置成Expanding来占位。
layout的作用,比方说,做一个json格式转换工具,可以先用一个HorizontalLayout设置两行,第一行工具栏,第二行是TextEdit输入输出界面。工具栏设置成VerticalLayout,装几个Button外加Spacer填充空位,输入输出可以直接占第二行的左右两边,也是搞成VerticalLayout。右边的TextEdit弄成只读,左边的TextEdit用于输入yaml、json等字符串,这样再结合自己实现的model跟service逻辑,整个模块就差不多做起来了。
时钟工具
时钟工具前端主要展现当前时间的绘制以及一系列timestamp、datetime和timestring的转换,整块难点主要在时钟的绘制上,没有一手资料,但经过一些源码研究还是可以解决的。由于pyside6本身有QLCDNumber控件的支持,所以绘制起来比较容易,官网也给了一个例子。笔者自己则在这个基础上做了下修改,代码如下:
python
import sys
from PySide6.QtCore import QTime, QTimer, Slot
from PySide6.QtWidgets import QApplication, QLCDNumber
class DigitalClock(QLCDNumber):
def __init__(self, parent=None):
super().__init__(parent)
self.setSegmentStyle(QLCDNumber.Flat) # 设置显示样式,可以看哪个比较美观
self.setDigitCount(8)
# 定时器每秒更新
self.timer = QTimer(self)
self.timer.timeout.connect(self.show_time)
self.timer.start(1000)
self.show_time()
@Slot()
def show_time(self):
time = QTime.currentTime()
text = time.toString("hh:mm:ss") # 直接格式化显示即可
# Blinking effect,视情况使用
# if (time.second() % 2) == 0:
# text = text.replace(":", " ")
self.display(text)
if __name__ == "__main__": # 测试代码
app = QApplication(sys.argv)
clock = DigitalClock()
clock.show()
sys.exit(app.exec())
而表盘时钟则相对难一些,主要是需要拿到一个比较好的效果。经过一番寻找,在这个例子里面展示的效果比较不错。笔者也是在这个基础上做了一点调整,详细代码如下:
python
from PySide6 import QtCore, QtGui, QtWidgets
from PySide6.QtCore import QPoint, QTimer, QTime, Qt
from PySide6.QtGui import QGuiApplication, QPainter, QPalette, QPolygon
class AnalogClock(QtWidgets.QWidget):
def __init__(self, parent=None):
super(AnalogClock, self).__init__(parent)
# 每1s更新绘图
self._timer = QtCore.QTimer(self)
self._timer.timeout.connect(self.update)
self._timer.start(1000)
# 绘制时针、分针、秒针,粗细形状可以调整QPoint参数
self._hour_hand = QPolygon([
QPoint(4, 14),
QPoint(-4, 14),
QPoint(-3, -55),
QPoint(3, -55)
])
self._minute_hand = QPolygon([
QPoint(4, 14),
QPoint(-4, 14),
QPoint(-3, -85),
QPoint(3, -85)
])
self._seconds_hand = QPolygon([
QPoint(1, 14),
QPoint(-1, 14),
QPoint(-1, -90),
QPoint(1, -90)
])
# 调色板,调整时针、分针、秒针和背景色
palette = qApp.palette()
self._background_color = palette.color(QPalette.ColorRole.Base)
self._hour_color = palette.color(QPalette.ColorRole.Text)
self._minute_color = palette.color(QPalette.ColorRole.Text)
self._seconds_color = palette.color(QPalette.ColorRole.Accent)
def paintEvent(self, event): # 重载paintEvent函数来画图
width = self.width()
height = self.height()
side = min(width, height)
with QPainter(self) as painter: # painter有前后处理,with一下
# 绘制背景
painter.fillRect(0, 0, width, height, self._background_color)
painter.setRenderHint(QPainter.Antialiasing)
painter.translate(width / 2, height / 2)
painter.scale(side / 200.0, side / 200.0)
time = QTime.currentTime()
# 通过setBrush设置绘图颜色,绘制时针
painter.setPen(Qt.NoPen)
painter.setBrush(self._hour_color)
painter.save()
painter.rotate(30.0 * ((time.hour() + time.minute() / 60.0)))
painter.drawConvexPolygon(self._hour_hand)
painter.restore()
# 绘制小时的12点
for _ in range(0, 12):
painter.drawRect(73, -3, 16, 6)
painter.rotate(30.0)
# 绘制分针
painter.setBrush(self._minute_color)
painter.save()
painter.rotate(6.0 * time.minute())
painter.drawConvexPolygon(self._minute_hand)
painter.restore()
# 绘制秒针,带一个针头
painter.setBrush(self._seconds_color)
painter.save()
painter.rotate(6.0 * time.second())
painter.drawConvexPolygon(self._seconds_hand)
painter.drawEllipse(-3, -3, 6, 6)
painter.drawEllipse(-5, -68, 10, 10)
painter.restore()
# 绘制分钟的60分
painter.setPen(self._minute_color)
for _ in range(0, 60):
painter.drawLine(92, 0, 96, 0)
painter.rotate(6.0)
HTTP请求工具
JSON工具的实现比较简单,这里按下不表,直接跳到重头戏,HTTP请求工具。要做一个类似postman的HTTP请求工具,首先界面设计方面,直接抄postman的一些基本元素就可以了,比如说:
- Request:Method、URL、SubmitButton、Headers、Body、Settings
- Response:Headers、Body、StatusCode、ElapsedTime
每个组件可能还有比较细微的需求,比如Headers可以做成QTableWidget的形式,支持增删改功能,用setRowCount、setItem、removeRow之类的接口就可以满足这些操作。Settings可以简单做成FormLayout,设置超时时间就可以。ElapsedTime则在ui设计上需要留空内容,请求的时候动态渲染当前请求消耗了多少秒钟。
ui设计差不多好了之后,我们需要写单独的一个http请求模块,让http请求的执行和审计可以独立出来运行。这是因为,桌面工具ui渲染是大头,不是说执行了一个http请求ui渲染就会阻塞,这样界面就会卡住了。怎么样不让界面卡住这是另一个话题,先解决http请求,让其可以单独执行。
我们可以基于requests库来实现http调用逻辑,需要单独把Request和Response类抽象出来。直接上代码:
python
import requests
class Response:
def __init__(self,
status_code: int = 200,
headers: Optional[Dict[str, str]] = None,
body: str = None):
# 只需要状态码、headers、body
self.status_code = status_code
self.headers = headers if isinstance(headers, dict) else {}
self.body = body
@classmethod
def from_response(cls, resp: requests.Response): # 从requests的返回中提取内容
status_code = resp.status_code
headers = dict(resp.headers)
body = resp.text
return cls(status_code, headers, body)
class RequestMethod:
GET = 'GET'
POST = 'POST'
PUT = 'PUT'
DELETE = 'DELETE'
PATCH = 'PATCH'
def _default_request_headers():
return {
'Content-Type': 'application/json',
}
class Request:
def __init__(self,
url: str = '',
method: str = RequestMethod.GET,
headers: Optional[Dict[str, str]] = None,
body: str = '',
settings: Optional[RequestSettings] = None):
# 对应界面设置里头的东西
self.url = url
self.method = method
self.headers = headers if isinstance(headers, dict) else _default_request_headers()
self.body = body
self.settings = settings if isinstance(settings, RequestSettings) else RequestSettings()
def validate(self):
if not self.url:
return ValueError('url is required')
if not self.method:
return ValueError('method is required')
return None
def args(self): # 组成requests的参数
return {
'method': self.method,
'url': self.url,
'headers': self.headers,
'data': self.body.strip(),
'timeout': (
self.settings.connect_timeout_seconds(),
self.settings.read_timeout_seconds()
)
}
def invoke(self) -> (Response, Exception):
try:
resp = requests.request(**self.args())
return Response.from_response(resp), None
except Exception as e:
return None, e
有了这些代码之后,单个http请求就可以独立运行,并且请求、返回的数据上下文也可以单独审计。
接下来要解决的问题是,怎么把view逻辑和这个request串联起来。这里需要用到QThread加上signal的机制,通过一个RequestWorker串联,保证http请求干扰不到ui运转。
View层面,逻辑可以简写成这样:
python
class ToolWidget(QWidget):
def __init__(self):
super(ToolWidget, self).__init__()
self.ui = Ui_ToolWidget()
self.ui.setupUi(self)
# request worker
self._request_worker: Optional[RequestWorkerThread] = None
# init actions and widget
self._init_actions()
self._init_widget()
def _init_actions(self):
# do request
self.ui.requestInvokeButton.clicked.connect(self.invoke_request)
# headers CRUD
self.ui.requestHeadersResetButton.clicked.connect(self.reset_request_headers)
self.ui.requestHeadersAddButton.clicked.connect(self.add_request_header)
self.ui.requestHeadersRemoveButton.clicked.connect(self.remove_request_header)
def _init_widget(self):
# fill in request
default_request = Request()
self.ui.requestMethodComboBox.setCurrentText(default_request.method)
# request headers
self.reset_request_headers()
# request settings
default_request_settings = default_request.settings
self.ui.requestSettingsConnectTimeoutLineEdit.setText(str(default_request_settings.connect_timeout))
self.ui.requestSettingsReadTimeoutLineEdit.setText(str(default_request_settings.read_timeout))
def _reset_request_state(self): # 重置request界面和worker
"""clear all previous request states"""
if self._request_worker is not None: # quit+wait+deleteLater三连
self._request_worker.quit()
self._request_worker.wait()
self._request_worker.deleteLater()
self._request_worker = None
self.ui.requestRespBodyTextEdit.clear()
self.ui.requestRespHeadersTableWidget.setRowCount(0)
self.ui.requestRespDetailTextEdit.clear()
def invoke_request(self):
LOGGER.debug('invoke request -> triggered')
if self._request_worker is not None and self._request_worker.isRunning():
LOGGER.warning(f'cannot invoke request, request worker is active')
return
self._reset_request_state()
req = self._gen_request()
LOGGER.debug(f'invoke request -> req: {req.args()}')
# request不同阶段的事件(signal),串联到ui层不同的回调
self._request_worker = RequestWorkerThread(req, parent=self)
self._request_worker.signals.start.connect(self.on_request_start)
self._request_worker.signals.progress.connect(self.on_request_progress)
self._request_worker.signals.finish.connect(self.on_request_finish)
self._request_worker.start(priority=QThread.Priority.LowPriority)
LOGGER.debug(f'invoke request -> thread started')
def on_request_start(self, _): # 请求开始,此时不能再发起请求
LOGGER.debug(f'request start')
self.ui.requestInvokeButton.setEnabled(False)
self._set_request_status('执行中')
self._set_request_duration(0)
def on_request_progress(self, evt: RequestProgressEvent): # 显示当前耗时,由worker提供
LOGGER.debug(f'request progress -> seconds: {evt.seconds}')
self._set_request_duration(evt.seconds)
def on_request_finish(self, evt: RequestFinishEvent): # 展示response数据
LOGGER.debug(f'request finish -> resp: {str(evt.resp)}, err: {evt.err}')
# set resp status
if evt.resp is None:
if evt.err is None:
self._set_request_status('无响应')
else: # 展示worker提供的错误信息
if isinstance(evt.err, (Timeout, ConnectTimeout, ReadTimeout)):
self._set_request_status('请求超时')
else:
self._set_request_status('请求异常')
else: # 展示状态码对应文案
status_code = evt.resp.status_code
if 200 <= status_code < 300:
self._set_request_status(f'{status_code} 成功')
elif 300 <= status_code < 400:
self._set_request_status(f'{status_code} 重定向')
elif 400 <= status_code < 500:
self._set_request_status(f'{status_code} 客户端错误')
elif 500 <= status_code < 600:
self._set_request_status(f'{status_code} 服务器错误')
else:
self._set_request_status(f'{status_code} 未知')
# set resp duration
self._set_request_duration(evt.seconds)
# set resp body
if evt.resp is None:
self.ui.requestRespBodyTextEdit.clear()
else:
body = json_pretty(evt.resp.body)
self.ui.requestRespBodyTextEdit.setText(body)
# set resp headers
if evt.resp is None:
self.ui.requestRespHeadersTableWidget.setRowCount(0)
else:
headers = evt.resp.headers
headers_size = len(headers.keys())
self.ui.requestRespHeadersTableWidget.setRowCount(headers_size)
r = 0
for k in sorted(headers.keys()):
v = headers[k]
self.ui.requestRespHeadersTableWidget.setItem(r, 0, QTableWidgetItem(k))
self.ui.requestRespHeadersTableWidget.setItem(r, 1, QTableWidgetItem(v))
r += 1
# set resp detail,审计信息
detail = self._gen_resp_detail(evt)
self.ui.requestRespDetailTextEdit.setText(detail)
# clear all states,请求完毕后,清除关联的request worker
if self._request_worker:
self._request_worker.quit()
self._request_worker.wait()
self._request_worker.deleteLater()
self._request_worker = None
self.ui.requestInvokeButton.setEnabled(True)
而RequestWorker可以这样写:
python
# 省略imports
# 用多进程,把request放到其他进程里,防止view层运行python代码阻塞
_REQUEST_POOL = ProcessPoolExecutor(max_workers=3)
def _executor():
return _REQUEST_POOL
class RequestStartEvent: # 请求开始的事件
def __init__(self):
pass
class RequestProgressEvent: # 请求中
def __init__(self, seconds: float):
self.seconds = seconds
class RequestFinishEvent: # 请求完成,如果发起失败或者有exception也算完成
def __init__(self,
req: Optional[Request],
resp: Optional[Response],
err: Optional[Exception],
seconds: float):
self.req = req
self.resp = resp
self.err = err
self.seconds = seconds
class RequestSignals(QObject): # 定义request的信号(事件)
start = Signal(RequestStartEvent)
progress = Signal(RequestProgressEvent)
finish = Signal(RequestFinishEvent)
class RequestWorkerThread(QThread):
def __init__(self, req: Request, parent=None):
QThread.__init__(self, parent)
self.req = req
self.signals = RequestSignals()
def run(self):
LOGGER.info(f'do request at thread: {str(QThread.currentThread())}')
req = self.req
if not isinstance(req, Request): # 类型不符,直接finish
evt = RequestFinishEvent(
req=None,
resp=None,
err=ValueError('req must be Request instance'),
seconds=0
)
self.signals.finish.emit(evt)
return
validate_err = req.validate()
if validate_err: # 校验失败,直接finish
evt = RequestFinishEvent(
req=req,
resp=None,
err=validate_err,
seconds=0
)
self.signals.finish.emit(evt)
return
self.signals.start.emit(RequestStartEvent()) # 开始请求,发事件
executor = _executor()
start_time = timeutil.now()
future = executor.submit(req.invoke) # submit给executor后返回future,可以随时调用done方法,看invoke是否完成了
while not future.done(): # 没完成就把当前耗时emit给到view层
cur_time = timeutil.now()
seconds = (cur_time - start_time).total_seconds()
evt = RequestProgressEvent(
seconds=seconds
)
self.signals.progress.emit(evt)
sleep_ms = random.randint(50, 150) # sleep一个间隔,防止当前py虚拟机阻塞
QThread.msleep(sleep_ms)
resp, err = future.result() # done了之后取result,即Response实例
end_time = timeutil.now()
seconds = (end_time - start_time).total_seconds()
evt = RequestFinishEvent(
req=req,
resp=resp,
err=err,
seconds=seconds
)
self.signals.finish.emit(evt) # 完成请求,发事件
这样,通过在worker里把request放到ProcessPoolExecutor里另一个进程,实时监控执行,再结合signal机制防止view层阻塞,就可以把简单的http请求能力串联起来。至于想了解ProcessPoolExecutor怎么运作的,可以参考笔者以前写的这篇ProcessPoolExecutor源码分析文章。
当然,作为一个postman工具,通常有把请求内容导出成curl的需求。这个场景下,我们需要用到一个叫做curlify的工具库来达到效果。curlify提供了一个to_curl函数,可以将一个请求实例转化成curl语句:
python
def to_curl(request, compressed=False, verify=True):
parts = [
('curl', None),
('-X', request.method),
]
for k, v in sorted(request.headers.items()):
parts += [('-H', '{0}: {1}'.format(k, v))]
if request.body:
body = request.body
if isinstance(body, bytes):
body = body.decode('utf-8')
parts += [('-d', body)]
if compressed:
parts += [('--compressed', None)]
if not verify:
parts += [('--insecure', None)]
# 下略,主要是组装命令
这里有一个坑点在于,我们有时候把requests转化为curl,得在请求之前去做,而我们一般用requests.get、requests.request时候,其实已经把请求发送出去了。因此,这个情况下我们需要简单探秘一下requests的源码实现,来看需要怎么做才能给到一个请求发送之前的requests实例。
python
def request(self): # requests.session.request,参数略
req = Request(
method=method.upper(),
url=url,
# 其他参数略
)
prep = self.prepare_request(req)
def prepare_request(self, req):
p = PreparedRequest()
p.prepare(
method=request.method.upper(),
url=request.url,
files=request.files,
data=request.data,
json=request.json,
headers=merge_setting(
request.headers, self.headers, dict_class=CaseInsensitiveDict
),
params=merge_setting(request.params, self.params),
auth=merge_setting(auth, self.auth),
cookies=merged_cookies,
hooks=merge_hooks(request.hooks, self.hooks),
)
return p
从requests源码中可以看到,在请求之前,会构造一个PreparedRequest实例,来存储所有的请求参数。因此,如果给定请求参数的话,我们也可以显式构造一个PreparedRequest实例,然后调用to_curl函数,将PreparedRequest转化为curl语句,满足在请求发起之间转化的需要。
python
def to_curl(self):
prepared_request = requests.PreparedRequest()
prepared_request.prepare_method(self.method)
prepared_request.prepare_url(self.url, None)
prepared_request.prepare_headers(self.headers)
prepared_request.prepare_body(self.body.strip(), None)
return curlify.to_curl(prepared_request)
部署打包
部署方面可以参考官网Deployment文档,采用pyside6-deploy工具来做二进制的部署。部署本身可能需要安装其他的python库,比如Nuitka,这些在自己的python环境里准备就好。部署默认会生成pysidedeploy.spec文件,是部署的配置文件,如果有部署不成功的问题可以改配置文件解决,比如:
- project_dir:项目路径,相对于你运行部署的路径,填写一个点就行
- input_file:相对的入口文件路径,比如main.py
- exec_directory:相对的输出路径,比如output、build、dist之类,按自己情况来
- python_path:venv可以设置成绝对路径
- packages:可以指定某些库要安装,如果当前python版本没有对应库的话可以改版本号试试
- qml_files:相对的qml文件路径。没有qml的话,需要随便指定某个文件,不然会把整个项目include进去,venv开发的话过不了
设置好了跑通之后,预期就会在exec_directory生成二进制文件了。
总结
以上便是通过pyside6开发简单python桌面工具的一整套流程。整体下来,要做好一套pyside6工具,界面注重简洁即可,主要是业务逻辑上确保可独立运行,和view层不要太多耦合。这样整个代码架构会更加灵活,工具内容可复用的潜力也会更多。