【Python】用PyMe实现自动使用nuitka生成pyd文件和相应pyinstaller打包命令

Python界面开发示例:用PyMe实现自动使用nuitka生成pyd文件和相应pyinstaller打包命令

文章目录


前言

作为一个业余人士,平时我会用Python开发一些小的工具,在这个过程中,界面的实现往往会让人头疼。一方面,那些较好的如PyQt之类,学习难度较高,另一方面,普通的tk等库,学习倒是容易些,但调整和维护起来,还是有些麻烦的。所以目前,我在使用PyMe作为我的python程序的界面设计工具


一、PyMe简介

1. 什么是PyMe

官方答案:PyMe是一款基于 Python 的流程化的项目开发工具软件,主要用于流程化、可视化、组件化的设计与开发应用和游戏项目
个人答案:使用PyMe能让我更方便的为自己的Python程序写界面

换言之,我使用的只是其中的一小部分功能,不过我相信,在Python被越来越多和我一样的“非专业人士”关注和应用的今天,界面设计对我们来说才是最重要的功能。它能够大大降低编程的门槛,让那些没有深厚编程背景的人也能够快速上手,创建出较为实用的桌面应用程序,未来或许还会有安卓程序(目前虽然已有打包安卓功能,但比较麻烦)。

顺便一提,即使是界面设计,我也只用了一点点功能,作为非专业人士写这个文章,只能说虽然是班门弄斧,但同时也是希望更多的人能够看到PyMe,了解PyMe,能够使用它来解决自身遇到的问题。

由于自身水平极为有限,也欢迎各路大神指点与指正。

2. PyMe的现状与展望

优点
PyMe的作者多年来仍然坚持在不断更新,确保该项目保持活力和发展。当前,PyMe的界面设计功能已经较为完善
1.易用性:PyMe提供了直观的拖拽式界面设计工具,使得创建用户界面变得非常简单,即使是编程新手也能快速上手。
2.强大功能:通过丰富的组件库和事件处理机制,PyMe支持实现复杂的交互逻辑,大大扩展了应用的功能范围。
3.便于维护:由于其模块化的设计理念,使用PyMe开发的应用程序易于维护和扩展。可以轻松集成现成的功能,减少开发时间和难度。

缺点
尽管PyMe有许多优点,但它也存在一些不足之处
1.不够稳定:在个别情况下可能会出现稳定性问题,比如表现不一致,或者一些bug,当然这些bug也会在更新中消失。
2.美观度有待提升:默认主题和样式可能不如一些专注界面的UI框架那样精致和现代化,这个也需要作者在功能完善后发力解决
3.官方只支持Python3.8:其实3.11,3.12也有人试过可以用,但以我的经历来看,极偶尔情况下,高版本可能会有一点问题

总的来说,PyMe已经在简化开发流程和降低技术门槛方面展现了巨大潜力,随着不断的更新,缺点也在逐步变少,期待在不久的将来,能够看到作为“完全体”的大成版本

3. 下载及收费方式

下载
可在PyMe官方网站下载,也可以进入作者创建的q或vx群下载
收费
下载和普通使用是免费的,部分功能收费,详见官网。在我个人看来,如果是开发些小工具什么的,使用免费功能就够了。相对重要的收费功能是,解除单个项目只能创建5个窗口的限制。当然,个人的小项目一般也用不上。

二、PyMe的界面介绍

PyMe主要有三大类窗口,分别是项目管理页,设计界面和代码编辑界面,先简单看一下,有个印象。比如后面提到“属性窗口”,如果不记得可以返回来看。

1. 项目管理页

在打开PyMe主程序后,显示的是项目管理页,这里主要用于新建项目和打开已有项目,以及登陆,也可以看一些实例项目等等。直接点确定就进入了设计界面
在这里插入图片描述

2. 设计界面

在这里插入图片描述

中间斜黑白格区域是设计区,可以看到有一个空窗口。左侧是最常用的,用来拖拽控件到窗口上。
右上是运行窗口,以及可以打包项目。
控件列表用于显示界面上有哪些控件,可以看到目前只有一个Form_1,也就是中间的灰色窗口(有时部分控件不好选择时,也可以点击控件列表)
右中是控件的属性窗口、事件窗口等,目前显示的是Form_1的属性.
下方是项目相关的文件,都是PyMe自动生成的。BackUp用于存备份,Resources用于存项目需要的一些资源文件,ico是一个默认的图标。

其中特别要注意的是2个文件,一是蛇形文件,它是窗口文件本身,在PyMe内双击它显示的就是窗口,实际它是一个.py的文件一般不需要手动改动,在这个项目中,它也是项目的入口文件。
另一个是CMD文件(也是个.py文件),虽然它也是自动生成的,但目前没多少东西,后续我们和窗口相关的事件代码主要写在这个文件内,CMD文件很重要,一定要记住它

3. 代码编辑界面

双击cmd文件后,会进入代码编辑页面。中间代码编辑区域就不说了,下方是调试,右侧是界面预览,作用是不用回设计界面就能知道哪个控件在哪里,它下方是控件可用的事件
在这里插入图片描述

值得一得的是,在代码编辑区域右键,会出现下图的快速代码菜单。由于目前只有一个Form_1控件,因此可以看到界面函数中,除了“全局函数”,仅有Form这一项(因为目前只有一个窗口,没有其它控件),如果有其它控件,会有更多选项。这里也可以让你知道一个控件有哪些可用的命令。比如对于Form来说,能够取得界面元素,能够销毁界面……。如果点击选项,就会自动生成相应的代码。
在这里插入图片描述
但是在我个人看来,PyMe的代码编辑器做的确实不太好用,毕竟个人作品,除非拿时间堆,否则不可能面面俱到,但缺的就是时间。所以我的建议是,特别对于离开代码智能提示就不会写代码的人来说,最好是用PyMe画控件、生成事件和PyMe的函数。
然后使用自己常用的编辑器,例如pycharm等,打开项目根目录,对自己的核心代码,以及窗口的cmd文件进行编辑,和项目的调试(调试的入口文件就是建立项目时自动生成的主窗口的py文件)。

三、案例应用演示

1. 需求描述

在用pyinstaller打包时(Pystand之类也一样),会有个问题,打包出来的程序的源代码可能会被反编绎,哪怕写的不一定多好,但毕竟自己的劳动成果,如何防范呢?方法之一就是可以使用nuitka或cython把自己的重要代码变成pyd文件,它是比较难被反编绎的。然后再用Pyinstaller打包pyd文件。但是项目里的py文件可能比较多,那么如何自动把这些文件变成pyd,并且生成pyinstaller能用的打包命令呢?

本次的案例就是从空界面开始,完整的做一个界面,在设置好项目目录路径、入口文件路径、pyd文件的保存路径等事项之后,把所有相关的py文件用nuitka --module打包,并生成类似于–add-binary=pydFiles\Function.pyd:. --exclude-module=Function这样的命令即可。这样用pyinstaller打包时就可以把这些命令粘贴过去,不需要手写了
受限于本人水平,案例的目的不是为了真的把能够通用的全套打包流程弄完,而是主要为了展示如何使用PyMe,所以某些细节可能说说的很琐碎。文末附项目代码。

观前提醒
1.本项目的完整代码,对于一些高分辨率电脑,可能会出现控件文字较大导致显示不全的情况,如果遇到此类问题手动把控件的文字字号调小即可。
2.由于csdn不好插入视频,所以都是以动图形式展示的,有时可能会不好分清动图是否刚刚开始,所以可以右键动图,在新标签页中打开图像,看完再关掉标签页,一般的浏览器都会有类似的功能

2. 新建项目

在项目管理页面选择空界面,修改一下项目路径,让目录名字更好记好认,然后确定,就新建了一个项目。顺便一提,一个新功能是,在项目管理页面,打开项目之前,可以右键项目,自动使用ai生成一个项目的图标
在这里插入图片描述

接着就进入了设计界面

3. 添加控件

3.1 修改窗口标题

选中Form后,可以先在Form的标题栏修改一下标题,当然,不只Form,其它控件都是一样道理,先选中控件,再修改属性,以后不再赘述
在这里插入图片描述
除了属性窗口,直接标题栏右键也可以修改图标,还有些自带的图标可用,改好了是下面这样:
在这里插入图片描述

3.2 添加控件

添加控件主要有2种方法,第1种是从左侧列表中单击想要的控件不放,拖动到Form上。第2种是在Form上右键,从菜单中创建控件。(其实还可以用快捷键添加,就不细说了)
在这里插入图片描述

3.3 修改控件的文字内容

一般有三种方法:
①右键控件,”设置文字内容“
②在右侧属性栏修改
③还可以在选中控件时,ctrl+Backspace键清空文字内容,就可以直接输入文字。
特别提醒,这个修改的是”控件显示的文字“,要把它和控件的名字区别开来,后面会说控件名称相关内容
在设计界面的左上角,还可以修改文字的字体和大小等:
在这里插入图片描述

3.4 复制控件

对于已有的控件,可以按住alt键不放,单击左键即完成复制,然后把上方的复制体拖走即可,当然也可以按住alt后左键不放,直接拖动到指定位置,类似下面这样。注意,对于锁定的控件无法使用后者,只能用前种方法先复制再拖走复制体
在这里插入图片描述

3.5 排布控件

排布控件有几种方式:
①直接鼠标拖动
②键盘的上下左右微调
③使用坐标
④框选多个控件后在快捷工具里对齐
特别说一下坐标的方式,如下图所示,想精确控制可以用
在这里插入图片描述

使用前面所说的生成和复制控件的方法,经过一通操作,此时一共完成了3个Label,3个Entry,3个Button和1个CheckButton,是下面这样:
在这里插入图片描述

3.6 添加表格

注意对于部分控件,如ListView,菜单栏等,可能需要编辑一些额外的信息,当然这些操作也可以通过代码来实现。
比如表格ListView的列名编辑,默认是ID,Name,age,现在需要修改为序号,py文件路径,pyd是否存在。这三列
在这里插入图片描述

3.7 添加信息显示用的Text,以及几个功能按钮

如下图,增加几个功能按钮,注意,如果使用框选来平均分布,需要把首尾2个按钮放到起止位置,中间的会自动平均分布
然后打包过程中需要显示一些信息,对于少量信息,可以用Label显示,但是对于打包这种信息较多情况,还是用text控件更好。
注意:text控件的创建,gif里没录到,简单说就是从左侧找到Text控件,拖到Form_1里,然后调整一下大小
在这里插入图片描述

3.8 锁定控件位置

对于确定好位置的控件,可以使用锁定,免得误操作把排好的控件拖走了(其实前面过程中就可以锁)。可以右键控件,选择锁定;也可以在右上的列表里右键锁定。已锁定的控件在列表里就是红色。注意,锁定的只是位置,大小是仍然可以调整的。下面是锁定的演示
在这里插入图片描述

3.9 已锁定控件的解锁

如果有位置不合适的控件,可以右键控件,原本的“锁定”选项就变成了“解锁”。
可以解锁后,调整好位置再锁定,然后结果大致是这样的(后面才发现,第2,3列最好设置为“居左”,此时可以右键表格,编辑列信息,选中相应列,把默认的居中变为居左,保存修改。这样后面的操作中,表格内容更方便看)
在这里插入图片描述

显然,使用拖拽来生成控件,比手写代码方便容易多了。

4. 修改控件名称

修改控件名称这一步骤并非是必要的,但是我认为有了这些步骤,才更方便管理和拓展我们的界面功能。

4.1 修改控件名称的方法

每个控件是有自己的名称的,以下图为例,这个Entry的默认名称是“Entry_1",既可以从右上的控件列表,也可以从右侧属性窗口看到。但是这样对我们后面对界面进行各种操作时不太方便,我们最好把它起个自己能看懂、好记的名字。
在这里插入图片描述

可以在控件上右键,”修改名称“,也可以属性窗口双击”名称“进行修改。

注意:虽然之前说过,但现在再次强调,控件名称是一个字符串,它是控件的名字,在一个Form里,不同的控件名字是不能重复的(例如自动生成的名称Entry_1和Entry_2是不同的,但是控件上显示的内容是可以重复的(例如2个按钮同样显示”浏览“)

4.2 控件命名示例

本项目中,控件命名的思路是这样的:
①对于Label,Form,我们不关心它能够提供什么值,可以不修改名称,用默认的就可以
②对于Entry,ListView,Text,CheckButton,我们需要取它们的值当参数,又或用它们显示一些信息,所以需要修改其名称,以便后续操作
③对于Button,我们不关心它能提供什么值,但由于按钮被点击时,会触发相应的事件函数,由于事件函数的命名是根据按钮的名称自动生成的。而我们希望能识别出,哪个事件函数对应哪个按钮,所以把它也要起个合适的名称。

下图就是给每个控件起的名称:
以第一个“浏览”按钮为例,我们起名为“项目目录浏览”,那么它的单击事件对应的函数,会自动命名为"项目目录浏览_onCommand",其它事件函数也都会命名为“项目目录浏览_xxxxxxx”之类的名称
在这里插入图片描述

5 生成按钮事件

5.1 生成事件的两种方法

此时我们可以点击右上角的运行,试着运行一下界面,会发现按钮可以点击,但没有其它反应,这是因为缺少事件。
在这里插入图片描述
那么如何生成事件?也有两种常用方法,一个是直接在控件上右键,选择“事件响应”,另一个是选中后,在属性窗口标签的旁边,点击“事件响应”标签,就能从属性窗口切到事件窗口,见下图。其中,方法一较常用于添加事件,方法二更适合“查看这个控件有什么对应的事件函数”
在这里插入图片描述

5.2 生成一个事件示例

见下图。我们使用右键点击第一个浏览按钮,选择”事件响应“(弹出窗口中的选项,和右侧事件窗口中的选项是一样的),选择第一个选项Command(即左键单击事件),点击”编辑代码”(也可以不点击编辑代码,直接双击Command,效果是一样的),就会自动进入代码界面,同时给CMD文件生成一个按钮的事件函数,意为“当按钮被点击时,做这个函数中写的事”,而且光标会自动停在这个函数里。
在这里插入图片描述
可以看到生成的事件函数名字为"项目目录浏览_onCommand",这也是修改按钮控件名称的目的,这个名称显然比默认的"Button_1_onCommand"好识别的多了。

5.3 事件函数的参数说明

事件函数自动生成了3个参数,uiName,widgetName,threadings。

uiName是界面的名称(对这个cmd文件而言,即蛇形窗口文件的名称AutoPack),从CMD文件的开头代码也能查到uiName=AutoPack。
有关uiName的作用,例如:
①如果我们要从A界面跳转到B界面,那么这个A、B指的就是它们的uiName,跳转语句需要用到
②比如我们写一个代码,让本界面某处显示“123”,这个代码中必然存在一个参数uiName。如果把uiName修改为"ABC",那么就是让ABC界面显示“123”了。先简单了解这些就够了,之后慢慢会熟悉的。

widgetName是控件的名称,也就是之前说的,比如名为"Button_1"的按钮,生成的事件函数,它的widgetName默认就是"Button_1"。而若我们把它的名字改为“项目目录浏览”,那么此按钮的widgetName=“项目目录浏览”。

threadings是多线程,如果事件函数耗时较久,那么函数运行完成前界面会卡住。为了让界面在程序运行时,不会卡住,我们往往需要使用多线程。而在PyMe中,threading=n就是“最多开n个线程”,n的目的是防止误动,比如n=1,就是连续点2下时第2下无效。如果n=2,那么最多同时运行2次该函数,和3次无效。默认0就是不用多线程。

5.4 解绑事件函数

如果一个控件有1个或多个事件,那么在选择事件的界面可以看到+符号。而右侧事件窗口虽然没+号但能直接看到对应的函数名称。
如果在PyMe的代码界面中,删除事件函数并ctrl+S保存,PyMe会自动提示,询问是否解绑,意思是“单击函数没了,按钮就没有单击事件了”,+号也就消失了
在这里插入图片描述

5.5 使用双击快速设置“鼠标单击”事件

除了左键单击外,还有很多事件,比如右击,双击,键盘按键等等,都可以作为事件触发各自的函数。
但对于最常用的”单击按钮“事件而言,有个更快的设置方式,就是直接在设计界面的控件上双击,下面是解绑和双击设置按钮事件的演示(提示:从代码界面退回到设计界面,除了右键外还可以直接esc键):
在这里插入图片描述

在上面的演示中可以看到,随着每次双击不同按钮,都会在CMD文件中增加一个该按钮的事件函数,而在第3个按钮的事件函数中,写入了一个Fun.MessageBox函数。
因此当界面运行时,点击此按钮就会触发单击事件,得到一个消息提示窗口

5.6 使用双击生成本项目所有按钮的单击事件

把界面上的7个按钮全部双击一遍,就会得到各按钮的事件函数。就像下图这样:
在这里插入图片描述

6. Fun的使用简介

这里顺便一提,Fun.py中存储着各项功能函数,我们需要的功能都是通过调用Fun函数实现的。调用的方式可以直接手写,也可以右键代码区域,手动选取需要的函数,例如下图:
在这里插入图片描述
这个右键菜单的意思是,当前的界面中有Form,Label,Button,Entry,CheckButton,Text,ListView这7种控件,其中Entry控件有3个,分别名为project_dir,main_file_path和pyd_dir。也就是说,通过这个菜单,我们可以知道任意界面已存在的控件,能够做哪些事,并且用鼠标点击相应命令,生成代码。而且代码的widgetName参数,会自动填入这个控件的名字,如下图:
在这里插入图片描述
由于在info_output控件的菜单下单击,生成的命令,所以widgetName="info_output"会自动填上,而如果是手动打这行代码,控件名称就需要自己填写了。

也有的Fun函数,可以不直接关联控件,例如下图示例的消息窗口函数:

在这里插入图片描述

总的来说,如果想知道某个控件,“能够执行什么命令”,一个是前面说的,可以右键后,在菜单里选择相应控件,查看有哪些选项。另外也可以右下角查看。例如下图,我们想知道作为ListView的table控件,能有哪些命令,下图是演示2种方法
在这里插入图片描述

7. PyMe和IDE的搭配使用

之所以要生成所有事件,是为了使用IDE打开这个项目,然后主要在IDE里编码,不用频繁切回PyMe了。
以Pycharm为例,使用它打开AutoPack文件夹,找到CMD文件,就可以在每个函数的下方编写事件内的代码了,当然,如果有新增的事件,或者不知道如何使用控件命令,都可以切回PyMe生成命令,保存后切回IDE就会看到生成好的命令了。
在IDE里运行主窗口AutoPack.py(它就是pyme里面显示蛇形的文件)同样能够正常运行该项目
在这里插入图片描述

8. 获取界面的输入数据

8.1 获取控件显示的文本

我们先熟悉一下PyMe的取值。假如我们希望获得“项目目录”输入框中的文本,先在PyMe中生成Fun的调用语句,再去IDE中编辑后续代码:
在这里插入图片描述
这里额外提醒一下,如果我们在PyMe中手动输入命令,会获得更多的提示:
在这里插入图片描述
上图可以看到,GetText适用于Label、Button等控件。等到熟练之后,其实在IDE里手写这些命令也不算麻烦

8.2 获取控件当前的值

以CheckButton为例,获取它当前的值,操作和8.1一样,就不录屏了,代码是
value = Fun.GetCurrentValue(uiName, ‘recompile’)
打勾时value为True,否则为False

9. 设置界面的输入数据类

这个步骤是我自己的习惯,不是必须的,但我们制作界面的重要目的之一,是为了获取数据,来当作参数传入函数的,那么,一个界面到底需要且能够传递哪些控件的参数?我认为是需要做一个“统计”的,这样才能方便来“取界面数据”,以及便于之后的维护和扩展。

我们在CMD文件中设置一个这样的类:(第35行代码需要修改为return copy.deepcopy(self.DATA)
在这里插入图片描述
这个类的作用,主要就是通过get_ui_data方法从界面中获取需要的内容(也可以用来对输入数据的合法性进行校验)

其实在PyMe代码界面右键–界面函数–全局函数–获取界面的输入数据字典,就可以得到Fun.GetUIDataDictionary函数,可以把界面上的所有数据以{控件名1:数据1,控件名2:数据2}的方式获取到。但里面有很多无用的数据,有点累赘。

而设置一个数据类既能方便获取数据,又能对“这个界面到底能获取哪些有用的数据”做个记录,而且后续可能还有别的用处(比如有时需要数据回退),可谓一举多得,不算浪费时间。

现在我们可以试验一下这个类的效果:
在这里插入图片描述

10. 设置界面的其它事件

10.1 浏览按钮

我们希望点击“浏览”之后,能够选取相应的路径,并把确定的路径显示在浏览前面的输入框内,注意第一个事件函数完成后,复制到其它函数时,对控件名称的修改,结合5.3的参数说明,这有助于理解事件函数的使用
在这里插入图片描述
可以运行一下试试:
在这里插入图片描述

10.2 Fun函数的封装

封装信息显示窗口
可以看到一直在使用print来显示信息。但是界面上明明有信息显示窗口,那么我们完全可以在这个窗口里显示,当然,我们可以直接使用Fun.。。。。来控制信息显示,但这样比较麻烦,毕竟我们有很多地方可能都要显示信息。

那么可以做个封装,在CMD文件中增加一个函数,注意info_output就是信息显示的Text类型控件的名字,不记得的话,可以翻回4.2节看控件名称的那张图
在这里插入图片描述

以后我们想在界面上显示信息,只需要用output代替掉print就可以了,是不是方便了很多?

封装消息窗口

其实还可以封装其它函数,例如下图中普通的消息窗口。注意,这个WINDOW不是必须的,也可以不写。
在这里插入图片描述
如果有其它常用函数,也可以自行封装。

11. 数据有效性的验证

11.1 验证哪些数据

用户输入的数据,有可能是错误的,甚至可能会造成程序报错,所以需要对界面的输入数据进行有效性验证,以本案例为例,需要对2个目录路径,和1个文件路径做校验,如果输入不符合要求,就弹出提示
当然,我们可以做2个函数,一个校验目录,一个校验文件,在但是Fun里已经自带了CheckExist函数了,这次就不用自己写了,统一用自带函数。
问题来了,这个校验加在哪里呢?我们回到PyMe的设计界面,右键任意一个输入框选择“事件响应”,或者在右侧事件窗口查看它有哪些事件
在这里插入图片描述

11.2 验证方法的选择

所以现在有3个选择:
1.当文字改变时检测输入框中的字符串是否合规
2.当失去焦点(即光标不在输入框内)时进行检测
3.当点击按钮时,对界面传入的所有数据进行验证

如果选1,会存在一个问题,比如手动输入E:\,当输入E时,它不是路径,这样就会造成在输入过程中触发很多不应有的错误提示。为了避免这点,我们需要把输入框的属性设置为“只读”,禁止手动输入就可以了。优点是省心、简洁,缺点是不能手动输入了
如果选2,同样会有一个问题,比如我在一个框里输入字符,然后直接点击按钮,虽然失去焦点的检测会正常触发,但是按钮已经点了,错误的参数已经随着按钮中的事件开始运行了。虽然有方法能限制这点(直接把失去焦点的函数在按钮事件内调用一次),但总归会多一步
如果选3,唯一缺点是不能在输入过程中就提示,只能在运行时才会提示

综合来说,还是3比较合适,但为了演示,还是暂时先用2来做一下,之后再注释掉这段代码即可。

11.3 试用“失去焦点”事件

我们就增加一个“失去焦点”的事件——右键后选择事件响应,然后双击FocusOut事件,生成相应事件函数
在这里插入图片描述

以此类推,把三个输入框都加上事件,然后封装一个验证的函数check_path,把这个函数写在三个事件内。

演示一下是这样的:当输入正常的路径时,就没反应,如果输入非路径的字符串,就会弹出提示窗口,如果有必要,这里甚至可以加上回退等功能。
在这里插入图片描述
不过,就像我之前说的那样,如果输入错误数据后直接点击按钮,等于让按钮事件无视数据检验直接发生了,所以还得在按钮里再检验一次数据,那还不如直接在按钮里检验了。

11.4 在输入数据类中添加数据验证方法

不论如何,失去焦点事件只是个演示,希望能够以此明确事件的添加方法,所以现在我还是把刚才的验证代码注释掉。用方法3,重新写一个数据检验函数check_params放入UiData类。
然后把check_params放到get_ui_data,这样只要获取界面内容,就会触发检测,进而影响获取的结果,然后只要根据获取的结果就能判断是否中止程序运行。
在这里插入图片描述
其实还有其它事件或按钮需要设置,但是暂时先不做,等遇到了再加

12. 完成功能函数并在界面中调用

cmd文件内主要用于存放界面相关的事件等函数,所以最好新建一个文件,专门写功能函数,就在项目目录内新建一个pack.py文件

从现在开始,gif可能比较难照抄代码了,甚至可能演示的代码和最终的代码会有细微不同,如果觉得麻烦,可以到文末先拿到pack.py的代码,以及cmd文件中输入数据类的代码。其它cmd文件的函数尽量自己写,因为本文的重点是如何使用PyMe而不是项目本身

12.1 “获取py文件”按钮的功能实现

这个功能需要读取项目文件夹内所有的符合要求的py文件,并判断其pyd是否存在。这里用到了我之前一篇文章中写的函数 筛选文件函数 ,可直接复制到pack.py内使用,然后写主要功能。可以在写的过程中调用一下,看有什么问题。(为了验证,特地在项目内新建了Function文件夹,里面新建了一个sy.py的文件里面只有一行print代码)
这里涉及到导入和调用,所以详细演示一下第1个函数get_table,之后的就不再赘述了
在这里插入图片描述

在这里插入图片描述

然后在cmd文件里,获取py文件按钮的事件中,输入代码,如果不知道表格的Fun函数,就切去PyMe的代码界面,右键-界面函数-ListView-table里看(其中table是之前给表格起的名字)
在这里插入图片描述
然后演示一下效果:
在这里插入图片描述

12.2 “nuitka打包”按钮的功能实现

既然已经拿到了所有的py文件信息,接着就可以用nuitka打包了,这个打包的相关代码,仍然写在Pack类中,然后在cmd文件中的“nuitka打包_onCommand”事件函数下调用打包(注意这个函数的参数改为threadings=1,因为如果等于0就会造成函数运行过程中界面卡顿,1即为新开1个线程处理任务,这样不会对界面自身造成影响)。在示例中,scons-debug.py是我特地复制的py文件,打包它时会报错,大概是因为名字中间有横杠。另外,中文路径也会造成错误。此外,这里的打包py文件都很小,所以一个个打也不会很慢。但如果大文件可能就非常慢了。后续可能需要用多进程优化一下。这里暂时就不做了。(此处显示的pack.py代码有点问题,以文末的最终代码为准)
在这里插入图片描述

12.3 增加表格的右键菜单

此时我们会发现,总有些会报错的,或者是不想打包的py文件出现在表格里,例如scons-debug.py和Fun.py,那么怎么办呢?我们可以为table增加一个右键菜单,让我们能手动删除选中的行,这样在打包时就不会被它干扰了。如何增加?这就得切回到PyMe的设计界面了

添加右键菜单

因为是右键菜单,所以触发事件方式肯定是右键table,下面是添加的事件的演示,过程中顺便添加了一个快捷键标识Ctrl+D,点击确定后,自动进入代码界面,并且新增了相关的函数。其中:
“table_onButton3”是右击table,出现菜单的事件
“table_onButton3_Menu_删除”则是在菜单中点击“删除”后触发的事件
在这里插入图片描述

接下来,只需要在"table_onButton3_Menu_删除"里,写入删除表格内选中行的代码就可以了。但是删除后,序号就不连贯了,所以我提前写了个update_ui_table函数,下面是添加删除函数的过程演示
在这里插入图片描述
解释一下,在删除时我使用了sort排序,这是因为一个小bug,已经提交了,在我发文时,已经修复了,不过这段代码不受更新的影响。
其实既然有删除py文件的功能,自然也可以有手动单独添加1个py文件的功能,但为了节约时间,这里就不做这功能了

12.4 添加表格右键菜单的快捷键

虽然之前已经选择了快捷键Ctrl+D,但那只是个菜单上的标识,实际上快捷键仍然没有生效,需要再添加实际功能
所以我们需要先选中Form_1,然后把右侧属性窗口切到事件窗口,找到Ctrl+D的事件并双击,下面是过程演示
在这里插入图片描述
注意最后的函数调用,按钮、菜单之类的事件函数都是可以作为一个普通函数被调用的,前提是传入正确的,符合相应类型的参数,在填写参数时,如果正常情况widgetName我会写"table",毕竟是table控件的事件,但是这里随便写个"123"也能运行,因为这个函数目前的功能不会受widget_name的影响。
再多嘴一句,什么情况下传入的参数无所谓呢?自然是看函数里面用不用的上这个参数,比如说你在table_onButton3_Menu_删除函数里加一行output(widgetName),那么显然传入widgetName就不能那么随意了——这个虽然简单,但有的基础薄弱者未必能转过弯来。

12.5 "打开pyd目录"按钮功能的实现

这个就简单了,直接看图,三步走:1.获取pyd目录(其实也可以用get_ui_data方法,但嫌麻烦) 2.判断是否是目录 3.打开目录
在这里插入图片描述

12.6 "保存设置"按钮功能的实现

其实演示过程中应该早有人感觉到了,每次进界面都要设置一遍,太麻烦了,所以从中间开始,我已经把输入框填好了默认的路径,但实际使用时,必须要能把上次的输入信息保存下来(包括表格中的数据,这样免得每次都得获取py文件然后手动删除一遍),而不是固定的数据。
为了方便,可以使用json来存储界面信息

由于这个是界面的功能,就还写在cmd文件里的UiData类下吧
在这里插入图片描述

12.7 已保存的设置文件的自动载入

每次界面刚开始运行时,需要先检查有没有存储文件,如果有就读取并显示到界面上。这个也是各类程序常用的功能。如何实现呢?
对于PyMe的界面,都会有些特殊事件,比如我们这次要用到的onload事件,它的作用是在界面加载完成后立刻触发。
在这里插入图片描述
也就是说,我们先写一个读取设置文件并显示到界面上的的函数set_ui_data,把它放到onload事件里,这样界面就会在加载完成后,自动运行set_ui_data函数,这样就不用每次都设置半天了。
顺便提一下,从上面的截图除了onload事件,也能够看到Exit事件,如果创建这个事件,并且把保存设置的save_ui_data放进去,就能实现每次关闭页面时自动保存设置,不需要手动保存了,不过这里就不做这个功能了

下面是自动加载设置文件功能实现的演示:
在这里插入图片描述

13. 增加清理垃圾功能

此时我们发现,打包后会有一堆过程产物,没什么用,看着闹心,那么不妨加一个清理按钮。此时又需要切回PyMe的设计界面了,制作一个名为"clean"的按钮,这里也使用了一些批量操作的功能:
在这里插入图片描述

然后加上一个清理垃圾文件的代码试运行一下:
在这里插入图片描述

14. 使用PyMe的发布功能为项目打包

在PyMe的设计界面右上角,有运行和发布按钮,代表着运行程序和打包两个功能,运行按钮的左边可以选择Windows、Web和Android。代表着想让我们的界面以何种方式运行和打包。当然,目前后两者还不够好用。所以就先打包默认的Window就好。下面是打包截图,其实倒也没什么好说的,因为填什么都有提示,是自动完成的。
在这里插入图片描述
顺带一提,我用nuitka把这个项目打包了一个单文件,也放到后面的网盘里了,打包前稍微优化了一点代码,最终以网盘文件为准

四、总结与案例代码下载

1. 完整功能演示

至此,我们的功能已基本实现,删掉设置文件,完整演示一遍吧:

在这里插入图片描述

2. 部分代码

完整的项目代码在后面的网盘下载链接。但为了方便使用,这里先把pack.py的全部代码贴出来,然后cmd的代码只把数据类和部分功能函数贴出来。从而能够让你专注于PyMe的操作和使用。

这里也建议,在使用这些代码的情况下,最好把PyMe本身的操作,以及事件相关的函数自己写一遍。相信练习完毕,你会对PyMe的使用有个初步的认知,和如何探索其更多功能的思路

pack.py文件:

# pack.py
import os
import subprocess
import shutil
import tkinter
from os.path import isfile

import Fun


def is_sub_path(path, dir_path):
    """
    判断给定的路径是否位于指定目录或其子目录中。

    :param path: 要检查的路径 (字符串)
    :param dir_path: 基准目录路径 (字符串)
    :return: 如果 path 位于 dir_path 或其子目录中则返回 True,否则返回 False
    """
    try:
        # 获取两个路径的绝对路径
        abs_path = os.path.abspath(path)
        abs_dir_path = os.path.abspath(dir_path)
        # 使用 commonpath 找出公共路径部分
        common = os.path.commonpath([abs_path, abs_dir_path])
        # 检查公共路径是否等于基准目录路径
        return common == abs_dir_path
    except Exception as e:
        print(f"发生错误: {e}")
        return False


def walk_file(path, ext=None, not_in=None, is_in=None, get_full_paths=True, deep=True):
    """
    列出文件夹中的文件, 并根据后缀、关键字筛选出来
    注意:若get_full_paths为False时,可能得到重复的文件名
    :param path: 根目录
    :param ext: 需要的文件的后缀名,可为单个后缀,或后缀列表。例如:'exe'或['exe', 'txt']
    :param not_in: 文件名中不能包含的字符。例如:'~$'或['~$', 'tmp']
    :param is_in: 文件名中必须包含的字符。例如:'abc'或['abc', 'def']
    :param get_full_paths: 默认True时返回文件的完整路径列表,否则返回文件名列表
    :param deep: 默认True,表示深度遍历所有子文件夹下的文件;若为False,只获取当前目录path下的文件
    :return: [files] or [file_paths]
    """
    # 检查给定路径是否为目录,如果不是则返回None
    if not os.path.isdir(path):
        return None

    # 参数规范化函数,将参数转换为小写列表
    def normalize_param(param):
        if param is None:
            return None
        if not isinstance(param, list):
            return [param.lower()]
        return [x.lower() for x in param]

    # 规范化处理ext,not_in,is_in参数
    ext = normalize_param(ext)
    not_in = normalize_param(not_in)
    is_in = normalize_param(is_in)

    # 初始化文件列表
    filelist = []

    # 根据deep参数决定是否深度遍历
    if not deep:
        # 只遍历当前目录
        for file in os.listdir(path):
            one_file_path = os.path.join(path, file)
            file_name = os.path.basename(one_file_path)
            f_ext = os.path.splitext(file_name)[1].lower()

            # 根据文件后缀,not_in,is_in筛选文件
            if ext is None or f_ext[1:] in ext:
                if not_in is None or all(ni not in file_name for ni in not_in):
                    if is_in is None or any(ii in file_name for ii in is_in):
                        # 根据get_full_paths参数决定添加文件的完整路径还是文件名
                        if get_full_paths:
                            filelist.append(os.path.abspath(one_file_path))
                        else:
                            filelist.append(file)
    else:
        # 深度遍历
        for root, _, files in os.walk(path):
            for file in files:
                one_file_path = os.path.join(root, file)
                file_name = os.path.basename(one_file_path)
                f_ext = os.path.splitext(file_name)[1].lower()
                # 同样的筛选逻辑
                if ext is None or f_ext[1:] in ext:
                    if not_in is None or all(ni not in file_name for ni in not_in):
                        if is_in is None or any(ii in file_name for ii in is_in):
                            # 根据get_full_paths参数决定添加文件的完整路径还是文件名
                            if get_full_paths:
                                filelist.append(os.path.abspath(one_file_path))
                            else:
                                filelist.append(file)
    # 返回文件列表
    return filelist


class Pack:
    def __init__(self):
        # *由界面指定的参数
        self.project_dir = ''
        self.main_file_path = ''
        self.pyd_dir = ''
        self.recompile = False
        # *手动指定的参数
        # !生成的pyd文件默认会带上这个后缀,根据各人电脑系统和python版本不同,可能有变化
        self.pyd_original_suffix = '.cp38-win_amd64'

    # 一般收到的界面的参数都需要先处理才能用
    def deal_params(self, uiData):
        # 以下为界面传来的参数提示
        """
        uiData = {
        'project_dir': '',
        'main_file_path': '',
        'pyd_dir': '',
        'recompile': False,
        }
        """
        self.project_dir = uiData.get('project_dir', '')
        self.main_file_path = uiData.get('main_file_path', '')
        # 如果没有输入pyd路径(即空字符串""),就默认是项目根目录
        self.pyd_dir = uiData.get('pyd_dir', '') if uiData.get('pyd_dir') else self.project_dir
        self.recompile = uiData.get('recompile', '')

    # 获取项目内所有的py文件列表,并检查其对应的pyd是否已经存在,返回结果的二维列表,用于界面上表格内显示
    def get_table(self, uiData):
        self.deal_params(uiData)
        # *如果嫌找到的不相干py文件太多,可以用walk_file的not_in参数,过滤掉包含['__init__', '__main__']之类字符的py文件
        pyfiles = walk_file(self.project_dir, ext='py', not_in=['__init__'], get_full_paths=True, deep=True)
        # 如果不用normpath,就无法比较路径,因为有的路径是\\有的是/组成
        main_path = os.path.normpath(self.main_file_path)
        # 一般项目的入口文件,以及pyd文件夹内的py文件,不需要变成pyd
        pyfiles = [item for item in pyfiles if not (
                is_sub_path(item, self.pyd_dir) or os.path.normpath(item) == main_path)]
        table = []
        for i, pyfile in enumerate(pyfiles):
            basename = os.path.basename(pyfile)
            pyd_file = os.path.join(self.pyd_dir, basename + 'd')
            if not os.path.exists(pyd_file):
                is_exist = '不存在'
            else:
                is_exist = os.path.relpath(pyd_file, self.project_dir)  # pyd在项目目录中的相对路径
            py_relpath = os.path.relpath(pyfile, self.project_dir)  # py在项目目录中的相对路径
            table.append([str(i + 1), py_relpath, is_exist])
        return table

    def nuitka_pack(self, table, uiData):
        # !注意:①项目路径中不要包含中文字符②即使是不同路径也不要包含同名的py文件,否则pyd文件也会重名
        pyinstaller_order_list = []
        self.deal_params(uiData)
        # os.chdir(self.project_dir)
        for i, row in enumerate(table):
            py_path = os.path.join(self.project_dir, row[1])
            py_base_name = os.path.basename(py_path)
            py_rel_path = os.path.relpath(os.path.dirname(py_path), self.project_dir)
            # 打包出的pyd初始名称会带后缀,此处记录初始名称和要重命名的名称,用于后面重命名
            pyd_original_file_path = os.path.join(self.pyd_dir, f'{py_base_name[:-3]}{self.pyd_original_suffix}.pyd')
            pyd_file_path = os.path.join(self.pyd_dir, f'{py_base_name[:-3]}.pyd')
            pyd_rel_path = os.path.relpath(pyd_file_path, self.project_dir)
            # 要么重编绎为真,要么pyd不存在,那么就需要编绎。否则不需要再编绎了
            if self.recompile or not os.path.isfile(pyd_file_path):  # *此处和文章12.2节的代码略有不同
                order = f'nuitka --module --output-dir={self.pyd_dir} {py_path}'
                output(f'开始打包 {py_path}...')
                res = self.run_order(order)
                if res is True:
                    # 防止重名文件,先删除
                    if os.path.isfile(pyd_file_path):
                        os.remove(pyd_file_path)
                    try:
                        # 将初始的pyd文件重命名
                        os.rename(os.path.normpath(pyd_original_file_path), os.path.normpath(pyd_file_path))
                        # 更新显示的表格的信息
                        table[i][2] = os.path.relpath(pyd_file_path, self.project_dir)
                        # 重新编绎后,将pyd加入命令
                        pyinstaller_order_list.append(f'--add-binary={pyd_rel_path}:{py_rel_path}')
                        pyinstaller_order_list.append(f'--exclude-module={py_base_name[:-3]}')
                        output(f'打包 {py_path}成功')
                    except FileExistsError:
                        table[i][2] = os.path.relpath(pyd_file_path, self.project_dir)
                        pyinstaller_order_list.append(f'--add-binary={pyd_rel_path}:{py_rel_path}')
                        pyinstaller_order_list.append(f'--exclude-module={py_base_name[:-3]}')
                        output(f'打包{py_path}时检测到已存在相应pyd文件')
                    except Exception as e:
                        output(f'打包{py_path}时出现未知错误:{e}')
                        table[i][2] = '打包失败'
                else:
                    output(f'打包 {py_path}失败')
                    # 更新显示的表格的信息
                    table[i][2] = '打包失败'
            else:
                # 即使不重新编绎,但仍有相应的pyd,直接加入命令
                pyinstaller_order_list.append(f'--add-binary={pyd_rel_path}:{py_rel_path}')
                pyinstaller_order_list.append(f'--exclude-module={py_base_name[:-3]}')
                output(f'已存在 {pyd_rel_path},跳过此打包')
        pyinstaller_order = ' '.join(pyinstaller_order_list)
        return table, pyinstaller_order

    @staticmethod
    def run_order(order):
        result = subprocess.run(order, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        return result.returncode == 0

    def clean_artifacts(self, data):
        self.deal_params(data)
        if not os.path.isdir(self.pyd_dir):
            output('清理失败,pyd目录设置错误')
            return
        artifacts = walk_file(self.pyd_dir, ext='pyi')
        for root, dirs, files in os.walk(self.pyd_dir):
            # 过滤出名字以 .build 结尾的文件夹
            for dir_name in dirs:
                if dir_name.endswith('.build'):
                    full_path = os.path.join(root, dir_name)
                    artifacts.append(full_path)
        if not artifacts:
            output(f'在{self.pyd_dir}内未找到可清理的文件')
            return
        for path in artifacts:
            res = self.delete(path)
            if res:
                output(f'清理 {path}成功')
            else:
                output(f'清理 {path}失败,错误为{res}')

    @staticmethod
    def delete(path):
        try:
            if os.path.isfile(path):
                os.remove(path)
            elif os.path.isdir(path):
                shutil.rmtree(path)
            else:
                return "未找到文件"
        except Exception as e:
            return e
        return True

def output(text):
    Fun.InsertText('AutoPack', 'info_output', tkinter.END, f'{text}\n\n', '')




CMD文件中的部分代码:

# AutoPack_cmd.py
WINDOW = Fun.GetElement(uiName, 'Form_1')
packer = pack.Pack()
def msg(text, title='提示'):
    Fun.MessageBox(text, title, 'info', WINDOW)


class UiData:
    msg_info = {
        'project_dir': '项目目录路径',
        'pyd_dir': 'pyd文件存储目录',
        'main_file_path': '项目入口文件'
    }
    json_path = 'option.json'
    def __init__(self):
        self.DATA = {
        'project_dir': '',
        'main_file_path': '',
        'pyd_dir': '',
        'recompile': False,
        }

    def check_params(self):
        for widget_name in UiData.msg_info:
            if not Fun.CheckExist(self.DATA.get(widget_name)):
                msg(f'{UiData.msg_info[widget_name]} 输入错误,请重新输入')
                return False
        return True

    def get_ui_data(self):
        """获取界面中,有用的值,便于功能函数获取参数"""
        for widget_name in self.DATA:
            if isinstance(self.DATA[widget_name], str):
                # 从控件中取文本
                self.DATA[widget_name] = Fun.GetText(uiName, widget_name)
            elif isinstance(self.DATA[widget_name], bool):
                # 从控件中取值
                self.DATA[widget_name] = Fun.GetCurrentValue(uiName, widget_name)
            else:
                raise '未找到合适的取值方式'
        # 参数的校验
        if self.check_params():  # 也可以在上面的for循环中校验
            return copy.deepcopy(self.DATA)
        else:
            return None

    def reset(self):
        """重置界面参数"""
        self.__init__()

    def save_ui_data(self):
        data = self.get_ui_data()
        if data is None:
            msg('界面的设置有错误,无法保存')
            return None
        table = Fun.GetAllRowTextList(uiName, 'table')
        data['table'] = table
        with open(self.json_path, 'w', encoding='utf-8') as file:
            json.dump(data, file)
        return True

    def set_ui_data(self, data):
        if not data or not isinstance(data, dict):
            return None
        try:
            for widget_name in data:
                widget_value = data.get(widget_name, '')
                if isinstance(self.DATA.get(widget_name), str):
                    # 在控件中设置文本
                    Fun.SetText(uiName, widget_name, widget_value)
                elif isinstance(self.DATA.get(widget_name), bool):
                    # 在控件中设置值
                    Fun.SetCurrentValue(uiName, widget_name, widget_value)
                elif widget_name == 'table':
                    # 如果没有值,或者是二维的空列表,就不写入表格数据
                    if (not widget_value) or  all(not sublist for sublist in data.get(widget_name)):
                        continue
                    Fun.AddMultiRowText(uiName, 'table', 'end', widget_value, [])
                else:
                    output('界面设置时,未找到合适的设置方式,因此不读取设置文件')
                    self.set_ui_data(self.DATA)
                    return False
            return True
        except Exception as e:
            output(f"对ui进行设置时出现错误: {e}")
            self.set_ui_data(self.DATA)
        return False

    def read_ui_data(self):
        if not os.path.exists(self.json_path):
            return None
        try:
            with open(self.json_path, 'r', encoding='utf-8') as file:
                data = json.load(file)
            return data
        except Exception as e:
            output(f"读取ui的设置文件时出现错误: {e}")
            return False


def output(text):
    Fun.InsertText(uiName, 'info_output', tkinter.END, f'{text}\n', '')

uiData = UiData()


def update_ui_table():
    """删除部分文件后更新界面上的表格"""
    # 获取删除后的表格
    table = Fun.GetAllRowTextList(uiName, 'table')
    for i, row in enumerate(table):
        # 每行的序号重新编号
        row[0] = str(i + 1)
    # table有内容时,展示内容
    if table and table != [[]]:
        # 清空表格
        Fun.DeleteAllRows(uiName, 'table')
        # 展示
        Fun.AddMultiRowText(uiName, 'table', 'end', table, [])

3. 项目下载

其实这个项目只是作为一个案例,还有很多可以改进的地方。
例如对项目入口文件、pyd目录,和项目目录之间关系的判断。
例如把读取设置文件做成一个按钮,然后保存设置时可以手动选择路径,放进各项目的文件夹内,这样就可以同时保存或读取多个项目的打包情况了
甚至可以把Pyinstaller其它相关命令乃至打包完整流程,都放进去

还有很多内容,有兴趣的可以自行发挥。下面是项目的下载方式(项目的代码可能会与前面稍有不同):
百度网盘

里面有3个文件,1.案例使用的PyMe1.4.8.3版本。2.项目的压缩包。3.项目打包后的exe单文件。
特别提醒:目前的exe文件,如果在高分辨率的电脑上,有可能显示出来的样子会和前面的截图不一样(因为高分辨率电脑往往会设置150%,200%等缩放比例,而非100%)。所以如果想要方便使用,最好还是自己做一遍,可以把字号调整小一些就正常了

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值