pyxllib 0.3.197__py3-none-any.whl → 0.3.200__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. pyxllib/__init__.py +21 -21
  2. pyxllib/algo/__init__.py +8 -8
  3. pyxllib/algo/disjoint.py +54 -54
  4. pyxllib/algo/geo.py +541 -541
  5. pyxllib/algo/intervals.py +964 -964
  6. pyxllib/algo/matcher.py +389 -389
  7. pyxllib/algo/newbie.py +166 -166
  8. pyxllib/algo/pupil.py +629 -629
  9. pyxllib/algo/shapelylib.py +67 -67
  10. pyxllib/algo/specialist.py +241 -241
  11. pyxllib/algo/stat.py +494 -494
  12. pyxllib/algo/treelib.py +149 -149
  13. pyxllib/algo/unitlib.py +66 -66
  14. pyxllib/autogui/__init__.py +5 -5
  15. pyxllib/autogui/activewin.py +246 -246
  16. pyxllib/autogui/all.py +9 -9
  17. pyxllib/autogui/autogui.py +852 -852
  18. pyxllib/autogui/uiautolib.py +362 -362
  19. pyxllib/autogui/virtualkey.py +102 -102
  20. pyxllib/autogui/wechat.py +827 -827
  21. pyxllib/autogui/wechat_msg.py +421 -421
  22. pyxllib/autogui/wxautolib.py +84 -84
  23. pyxllib/cv/__init__.py +5 -5
  24. pyxllib/cv/expert.py +267 -267
  25. pyxllib/cv/imfile.py +159 -159
  26. pyxllib/cv/imhash.py +39 -39
  27. pyxllib/cv/pupil.py +9 -9
  28. pyxllib/cv/rgbfmt.py +1525 -1525
  29. pyxllib/cv/slidercaptcha.py +137 -137
  30. pyxllib/cv/trackbartools.py +251 -251
  31. pyxllib/cv/xlcvlib.py +1040 -1040
  32. pyxllib/cv/xlpillib.py +423 -423
  33. pyxllib/data/echarts.py +240 -240
  34. pyxllib/data/jsonlib.py +89 -89
  35. pyxllib/data/oss.py +72 -72
  36. pyxllib/data/pglib.py +1127 -1127
  37. pyxllib/data/sqlite.py +568 -568
  38. pyxllib/data/sqllib.py +297 -297
  39. pyxllib/ext/JLineViewer.py +505 -505
  40. pyxllib/ext/__init__.py +6 -6
  41. pyxllib/ext/demolib.py +246 -246
  42. pyxllib/ext/drissionlib.py +277 -277
  43. pyxllib/ext/kq5034lib.py +12 -12
  44. pyxllib/ext/old.py +663 -663
  45. pyxllib/ext/qt.py +449 -449
  46. pyxllib/ext/robustprocfile.py +497 -497
  47. pyxllib/ext/seleniumlib.py +76 -76
  48. pyxllib/ext/tk.py +173 -173
  49. pyxllib/ext/unixlib.py +827 -827
  50. pyxllib/ext/utools.py +351 -351
  51. pyxllib/ext/webhook.py +124 -119
  52. pyxllib/ext/win32lib.py +40 -40
  53. pyxllib/ext/wjxlib.py +88 -88
  54. pyxllib/ext/wpsapi.py +124 -124
  55. pyxllib/ext/xlwork.py +9 -9
  56. pyxllib/ext/yuquelib.py +1105 -1105
  57. pyxllib/file/__init__.py +17 -17
  58. pyxllib/file/docxlib.py +761 -761
  59. pyxllib/file/gitlib.py +309 -309
  60. pyxllib/file/libreoffice.py +165 -165
  61. pyxllib/file/movielib.py +148 -148
  62. pyxllib/file/newbie.py +10 -10
  63. pyxllib/file/onenotelib.py +1469 -1469
  64. pyxllib/file/packlib/__init__.py +330 -330
  65. pyxllib/file/packlib/zipfile.py +2441 -2441
  66. pyxllib/file/pdflib.py +426 -426
  67. pyxllib/file/pupil.py +185 -185
  68. pyxllib/file/specialist/__init__.py +685 -685
  69. pyxllib/file/specialist/dirlib.py +799 -799
  70. pyxllib/file/specialist/download.py +193 -193
  71. pyxllib/file/specialist/filelib.py +2829 -2829
  72. pyxllib/file/xlsxlib.py +3131 -3131
  73. pyxllib/file/xlsyncfile.py +341 -341
  74. pyxllib/prog/__init__.py +5 -5
  75. pyxllib/prog/cachetools.py +64 -64
  76. pyxllib/prog/deprecatedlib.py +233 -233
  77. pyxllib/prog/filelock.py +42 -42
  78. pyxllib/prog/ipyexec.py +253 -253
  79. pyxllib/prog/multiprogs.py +940 -940
  80. pyxllib/prog/newbie.py +451 -451
  81. pyxllib/prog/pupil.py +1197 -1197
  82. pyxllib/prog/sitepackages.py +33 -33
  83. pyxllib/prog/specialist/__init__.py +391 -391
  84. pyxllib/prog/specialist/bc.py +203 -203
  85. pyxllib/prog/specialist/browser.py +497 -497
  86. pyxllib/prog/specialist/common.py +347 -347
  87. pyxllib/prog/specialist/datetime.py +198 -198
  88. pyxllib/prog/specialist/tictoc.py +240 -240
  89. pyxllib/prog/specialist/xllog.py +180 -180
  90. pyxllib/prog/xlosenv.py +108 -108
  91. pyxllib/stdlib/__init__.py +17 -17
  92. pyxllib/stdlib/tablepyxl/__init__.py +10 -10
  93. pyxllib/stdlib/tablepyxl/style.py +303 -303
  94. pyxllib/stdlib/tablepyxl/tablepyxl.py +130 -130
  95. pyxllib/text/__init__.py +8 -8
  96. pyxllib/text/ahocorasick.py +39 -39
  97. pyxllib/text/airscript.js +744 -744
  98. pyxllib/text/charclasslib.py +121 -121
  99. pyxllib/text/jiebalib.py +267 -267
  100. pyxllib/text/jinjalib.py +32 -32
  101. pyxllib/text/jsa_ai_prompt.md +271 -271
  102. pyxllib/text/jscode.py +922 -922
  103. pyxllib/text/latex/__init__.py +158 -158
  104. pyxllib/text/levenshtein.py +303 -303
  105. pyxllib/text/nestenv.py +1215 -1215
  106. pyxllib/text/newbie.py +300 -300
  107. pyxllib/text/pupil/__init__.py +8 -8
  108. pyxllib/text/pupil/common.py +1121 -1121
  109. pyxllib/text/pupil/xlalign.py +326 -326
  110. pyxllib/text/pycode.py +47 -47
  111. pyxllib/text/specialist/__init__.py +8 -8
  112. pyxllib/text/specialist/common.py +112 -112
  113. pyxllib/text/specialist/ptag.py +186 -186
  114. pyxllib/text/spellchecker.py +172 -172
  115. pyxllib/text/templates/echart_base.html +10 -10
  116. pyxllib/text/templates/highlight_code.html +16 -16
  117. pyxllib/text/templates/latex_editor.html +102 -102
  118. pyxllib/text/vbacode.py +17 -17
  119. pyxllib/text/xmllib.py +747 -747
  120. pyxllib/xl.py +42 -39
  121. pyxllib/xlcv.py +17 -17
  122. {pyxllib-0.3.197.dist-info → pyxllib-0.3.200.dist-info}/METADATA +1 -1
  123. pyxllib-0.3.200.dist-info/RECORD +126 -0
  124. {pyxllib-0.3.197.dist-info → pyxllib-0.3.200.dist-info}/licenses/LICENSE +190 -190
  125. pyxllib-0.3.197.dist-info/RECORD +0 -126
  126. {pyxllib-0.3.197.dist-info → pyxllib-0.3.200.dist-info}/WHEEL +0 -0
@@ -1,940 +1,940 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- # @Author : 陈坤泽
4
- # @Email : 877362867@qq.com
5
- # @Date : 2024/11/12
6
-
7
- from collections import defaultdict
8
- from types import SimpleNamespace
9
- from unittest.mock import Mock
10
- import ctypes
11
- import datetime
12
- import os
13
- import socketserver
14
- import subprocess
15
- import sys
16
- import time
17
- import textwrap
18
- import threading
19
-
20
- from deprecated import deprecated
21
- from loguru import logger
22
- from croniter import croniter
23
- import pandas as pd
24
-
25
- from fastapi import FastAPI
26
- from fastapi.responses import PlainTextResponse
27
-
28
- from pyxllib.prog.specialist import parse_datetime
29
- from pyxllib.algo.stat import print_full_dataframe
30
- from pyxllib.file.specialist import XlPath
31
-
32
-
33
- def __1_定时工具():
34
- pass
35
-
36
-
37
- class SchedulerUtils:
38
- @classmethod
39
- def calculate_future_time(cls, start_time, wait_seconds):
40
- """ 计算延迟时间
41
-
42
- :param datetime start_time: 开始时间
43
- :param int wait_seconds: 等待秒数
44
- todo 先只支持秒数这种标准秒数,后续可以考虑支持更多智能的"1小时"等这种解析
45
- """
46
- return start_time + datetime.timedelta(seconds=wait_seconds)
47
-
48
- @classmethod
49
- def calculate_next_cron_time(cls, cron_tag, base_time=None):
50
- """ 使用crontab标记的运行周期,然后计算相对当前时间,下一次要启动运行的时间
51
-
52
- :param str cron_tag: 自定义的cron标记,跟asp的差不多,但星期几部分做了调整
53
- 30 2 * * 1: 这部分是时间和日期的设定,具体含义如下:
54
- 30: 表示分钟,即每小时的第 30 分钟。
55
- 2: 表示小时,即凌晨 2 点。
56
- 第三个星号 *: 表示日,这里的星号意味着每天。
57
- 第四个星号 *: 表示月份,星号同样表示每个月。
58
- 1: 表示星期中的日子,这里的 1 代表星期一,7表示星期日。不能写1~7以外的值。
59
- :param datetime base_time: 基于哪个时间点计算下次时间
60
- """
61
-
62
- # 如果没有提供基准时间,则使用当前时间
63
- if base_time is None:
64
- base_time = datetime.datetime.now()
65
- # 初始化 croniter 对象
66
- cron = croniter(cron_tag, base_time)
67
- # 计算下一次运行时间
68
- next_time = cron.get_next(datetime.datetime)
69
- return next_time
70
-
71
- @classmethod
72
- def wait_until_time(cls, dst_time):
73
- """
74
- :param datetime dst_time: 一直等待到目标时间
75
- 期间可以用time.sleep进行等待
76
- """
77
- # 一般来说,只要计算一轮待等待秒数就行。但是time.sleep机制好像不一定准确的,所以使用无限循环重试会更好。
78
- while True:
79
- # 先计算当前时间和目标时间的相差秒数
80
- wait_seconds = (dst_time - datetime.datetime.now()).total_seconds()
81
- if wait_seconds <= 0:
82
- break
83
- time.sleep(max(1, wait_seconds)) # 最少等待1秒
84
-
85
- @classmethod
86
- def smart_wait(cls, start_time, end_time, wait_tag, print_mode=0):
87
- """ 智能等待,一般用在对进程的管理重启上
88
-
89
- :param datetime start_time: 程序启动的时间
90
- :param datetime end_time: 程序结束的时间
91
- :param str|float|int wait_tag: 等待标记
92
- str,按crontab解析
93
- 在end_time后满足条件的下次时间重启
94
- int|float,表示等待的秒数
95
- 正值是end_time往后等待,负值是start_time开始计算下次时间。
96
- 比如1点开始的程序,等待半小时,但是首次运行到2点才结束
97
- 那么正值就是2:30再下次运行
98
- 但是负值表示1:30就要运行,已经错过了,马上2点结束就立即启动复跑
99
- """
100
- # 1 尝试把wait_tag转成数值
101
- try:
102
- wait_tag = float(wait_tag)
103
- except ValueError: # 转不成也没关系
104
- pass
105
-
106
- if start_time is None:
107
- start_time = datetime.datetime.now()
108
- if end_time is None:
109
- end_time = datetime.datetime.now()
110
-
111
- # 2 计算下一次启动时间
112
- if isinstance(wait_tag, str):
113
- # 按照crontab解析
114
- next_time = cls.calculate_next_cron_time(wait_tag, end_time)
115
- elif wait_tag >= 0:
116
- # 正值则是从end_time开始往后等待
117
- next_time = cls.calculate_future_time(end_time, wait_tag)
118
- elif wait_tag < 0:
119
- # 负值则是从start_time开始往前等待
120
- next_time = cls.calculate_future_time(start_time, wait_tag)
121
- else:
122
- raise ValueError
123
-
124
- if print_mode:
125
- print(f'等待到时间{next_time}...')
126
-
127
- cls.wait_until_time(next_time)
128
-
129
-
130
- def __2_程序管理():
131
- pass
132
-
133
-
134
- def find_free_ports(count=1):
135
- """ 随机获得可用端口
136
-
137
- :param count: 需要的端口数量(会保证给出的端口号不重复)
138
- :return: list
139
- """
140
- ports = set()
141
- while len(ports) < count:
142
- with socketserver.TCPServer(("localhost", 0), None) as s:
143
- ports.add(s.server_address[1])
144
-
145
- return list(ports)
146
-
147
-
148
- class ProgramWorker(SimpleNamespace):
149
- """
150
- 代表一个单独的程序(进程),基于 SimpleNamespace 实现。
151
- """
152
-
153
- def __init__(self, name,
154
- cmd,
155
- shell=False,
156
- run=True,
157
- port=None,
158
- locations=None,
159
- raw_cmd=None,
160
- **attrs):
161
- """
162
- :param name: 程序昵称
163
- :param program: 程序对象
164
- :param port: 是否有执行所在端口
165
- :param locations: 是否有url地址映射,一般用于nginx等配置
166
- """
167
- super().__init__(**attrs)
168
-
169
- # 执行程序需要使用的参数
170
- self.cmd = cmd
171
- self.shell = shell
172
- self.run = run
173
-
174
- # 关键参数
175
- self.name = name
176
- self.program = None
177
- self.raw_cmd = raw_cmd
178
-
179
- # 这两个是比较特别的属性,在我的工程框架中常用
180
- self.port = port
181
- self.locations = locations
182
-
183
- # 特殊调度模式,需要用到程序启动、结束时间
184
- self.last_start_time = None
185
- self.last_end_time = None
186
-
187
- def _set_pdeathsig(self, sig=None):
188
- """ 在主服务退出时,这些程序也会全部自动关闭 """
189
-
190
- def callable():
191
- import signal
192
- sig2 = signal.SIGTERM if sig is None else sig
193
- libc = ctypes.CDLL("libc.so.6")
194
- return libc.prctl(1, sig2)
195
-
196
- if sys.platform == 'win32':
197
- # windows系统暂设为空
198
- return None
199
- else:
200
- return callable
201
-
202
- def lanuch(self):
203
- """ 启动程序 """
204
- self.last_start_time = datetime.datetime.now()
205
- self.last_end_time = None
206
-
207
- if not self.run:
208
- # 如果不需要立即启动,则返回一个 Mock 对象
209
- proc = Mock()
210
- proc.pid = None
211
- proc.poll.return_value = 'tag'
212
- self.program = proc
213
- return self.program
214
-
215
- kwargs = {}
216
- for name in ['stdin', 'stdout', 'stderr']:
217
- if hasattr(self, name):
218
- kwargs[name] = getattr(self, name)
219
-
220
- if sys.platform == 'win32':
221
- self.program = subprocess.Popen(self.cmd, shell=self.shell, **kwargs)
222
- else:
223
- self.program = subprocess.Popen(self.cmd, shell=self.shell, **kwargs,
224
- preexec_fn=self._set_pdeathsig())
225
-
226
- return self.program
227
-
228
- def terminate(self):
229
- """
230
- 比较优雅结束进程的方法
231
- """
232
- if self.program is not None:
233
- self.program.terminate()
234
- if self.last_end_time is None:
235
- self.last_end_time = datetime.datetime.now()
236
-
237
- def kill(self):
238
- """
239
- 有时候需要强硬的kill方法来结束进程
240
- """
241
- if self.program is not None:
242
- self.program.kill()
243
- if self.last_end_time is None:
244
- self.last_end_time = datetime.datetime.now()
245
-
246
- def is_running(self):
247
- """ 检查程序是否在运行 """
248
- status = self.program is not None and self.program.poll() is None
249
- if not status and self.last_end_time is None: # 检测到程序运行结束,则标记下
250
- self.last_end_time = datetime.datetime.now()
251
- return status
252
-
253
-
254
- class MultiProgramLauncher:
255
- """
256
- 管理多个程序的启动与终止
257
- """
258
-
259
- def __init__(self):
260
- self.workers = []
261
- self.scheduler = None
262
-
263
- def __1_进场管理的核心底层函数(self):
264
- pass
265
-
266
- def init_scheduler(self):
267
- from apscheduler.schedulers.background import BackgroundScheduler
268
- # from apscheduler.schedulers.asyncio import AsyncIOScheduler
269
-
270
- if self.scheduler is None:
271
- self.scheduler = BackgroundScheduler()
272
-
273
- return self.scheduler
274
-
275
- def worker_add_schedule(self, worker, schedule=None, misfire_grace_time=None):
276
- """ 将程序添加为定时任务
277
-
278
- :param int|float|str|list schedule: 定时任务配置,如果提供则添加到 APScheduler 调度器
279
- int/float: 每隔多少秒执行一次,可能同时有多个实例存在
280
- tuple(value1, value2): 只允许单实例运行,这里记录的是上次实例结束后,等待多久再开启下次实例
281
- 此时第1个数值,正数表示等待上一次运行结束后,下次开始运行前的等待时间
282
- 负数表示是在上次启动后,等待多久就可以运行下一次
283
- 但下一次一定会在上一次运行结束后再续上
284
- 第2个参数,为了实现这套功能,其实需要一个监视器不断监控程序的运行状态
285
- 第2个参数是监控器检测的频率秒数
286
- str: cron 表达式,支持 5 或 6 个字段
287
- list[datetime]: 在指定时间节点列表运行
288
- 值只要是可以解析为datetime类型的都可以,底层使用特殊的解析器
289
- """
290
- from datetime import datetime, timedelta
291
-
292
- def task():
293
- """ 启动任务并更新状态 """
294
- # todo schedule不一定都是单实例阻塞情景的需求,以后有需要可以扩展支持多实例同时存在的非阻塞模式
295
- if worker.is_running():
296
- logger.warning(f'由于程序"{name}"在上一次周期还没运行完,新周期不重复启动')
297
- else:
298
- worker.lanuch()
299
-
300
- self.init_scheduler()
301
-
302
- name = worker.name
303
-
304
- # 处理 schedule 类型
305
- if isinstance(schedule, (int, float)):
306
- # 如果是单一数值,按固定间隔执行,不考虑任务是否完成
307
- self.scheduler.add_job(task, 'interval', seconds=abs(schedule))
308
- logger.info(f"已添加定时任务:{name},监测频率:{schedule} 秒")
309
-
310
- elif isinstance(schedule, tuple) and len(schedule) == 2:
311
- wait_time, monitor_frequency = schedule
312
-
313
- def interval_task():
314
- # 如果还没有启动过任务,直接启动
315
- if worker.last_start_time is None:
316
- worker.lanuch()
317
- return
318
-
319
- # 如果任务正在运行,则不启动新任务
320
- if worker.is_running():
321
- return
322
-
323
- # 计算下次启动时间
324
- if wait_time < 0: # 基于上次启动时间
325
- next_run = worker.last_start_time + timedelta(seconds=abs(wait_time))
326
- else: # 基于上次结束时间
327
- next_run = worker.last_end_time + timedelta(seconds=wait_time)
328
-
329
- # 看现在是否需要重启
330
- if datetime.now() >= next_run:
331
- worker.lanuch()
332
-
333
- # 以 monitor_frequency 作为定时检查的间隔
334
- self.scheduler.add_job(interval_task, 'interval', seconds=monitor_frequency)
335
- logger.info(f"已添加定时任务:{name},等待时间: {wait_time} 秒,监测频率: {monitor_frequency} 秒")
336
-
337
- elif isinstance(schedule, str):
338
- from apscheduler.triggers.cron import CronTrigger
339
- cron_parts = schedule.split()
340
- # 如果是 5 个字段,则补齐 "秒" 字段为 '0'
341
- if len(cron_parts) == 5:
342
- cron_parts.insert(0, '0')
343
- # 检查是否为有效的 6 字段 cron 表达式
344
- if len(cron_parts) == 6:
345
- # 统一处理为 6 字段格式
346
- # 把我自定义的cron的星期标记转换为aps的星期标记。前者用1234567,后者用0123456表示星期一到星期日
347
- x = cron_parts[5]
348
- if x != '*': # 写0或7都表示周日
349
- if x == '0':
350
- x = '7'
351
- x = (int(x) - 1)
352
- self.scheduler.add_job(
353
- task,
354
- CronTrigger(
355
- second=cron_parts[0],
356
- minute=cron_parts[1],
357
- hour=cron_parts[2],
358
- day=cron_parts[3],
359
- month=cron_parts[4],
360
- day_of_week=x,
361
- ),
362
- # 检测的时候有概率错过了精确时间点。但一般不论延迟了多久,都要补运行上。
363
- misfire_grace_time=misfire_grace_time,
364
- )
365
- logger.info(f"已添加定时任务:{name},触发器: cron,表达式: {schedule}")
366
- else:
367
- logger.warning(f"无效的 cron 表达式:{schedule}")
368
-
369
- elif isinstance(schedule, list):
370
- # 支持 list[datetime] 格式
371
- for run_time in schedule:
372
- run_time = parse_datetime(run_time)
373
- self.scheduler.add_job(task, 'date', run_date=run_time)
374
- logger.info(f"已添加定时任务:{name},触发器: dates,运行时间: {schedule}")
375
-
376
- else:
377
- logger.warning(f"无效的调度格式,跳过任务:{name}")
378
-
379
- def add_program_cmd(self,
380
- cmd,
381
- name=None,
382
- shell=False,
383
- run=True,
384
- schedule=None,
385
- **attrs):
386
- """
387
- 启动一个程序,或仅存储任务,并添加进管理列表
388
-
389
- :param cmd: 启动程序的命令
390
- :param name: 程序名称,如果未提供则从cmd中自动获取
391
- :param shell:
392
- False,(优先推荐)直接跟系统交互,此时cmd应该输入数组格式
393
- True,启用shell进行交互操作,这种情况会更适合管道等模式的处理,此时cmd应该输入字符串格式
394
- :param int|float|str|list schedule: 定时任务配置,如果提供则添加到 APScheduler 调度器
395
- :param run: 如果为True,则立即启动程序;否则仅存储任务信息
396
- :param attrs: 其他需要传递给ProgramWorker的参数
397
- :return: 返回一个ProgramWorker实例,表示启动的程序或存储的任务
398
- """
399
- # 1 如果未显式传入name,自动从cmd中获取程序名称
400
- # logger.info(cmd)
401
- if name is None:
402
- _cmd = cmd.split() if isinstance(cmd, str) else cmd
403
- name = _cmd[0]
404
-
405
- # 2 创建 ProgramWorker 实例
406
- worker = ProgramWorker(name, cmd, shell, run, raw_cmd=cmd, **attrs)
407
- self.workers.append(worker)
408
-
409
- # 3 如果有 schedule 配置,添加调度任务;否则就是正常的启动任务
410
- if schedule:
411
- self.worker_add_schedule(worker, schedule=schedule, misfire_grace_time=attrs.get('misfire_grace_time'))
412
- else:
413
- worker.lanuch()
414
-
415
- return worker
416
-
417
- def add_program_cmd2(self, cmd, ports=1, name=None, **kwargs):
418
- """
419
- 增强版 add_program_cmd,支持 ports 的处理
420
-
421
- :param ports:
422
- int 表示要开启的进程数,端口号随机生成
423
- list 表示指定的端口号
424
- None 不做特殊配置
425
- """
426
- # 1 处理 ports 参数,找到空闲端口或使用指定端口
427
- if isinstance(ports, int):
428
- ports = find_free_ports(ports)
429
-
430
- # 2 处理 locations 参数,自动设置默认 URL 映射
431
- if name is None:
432
- _cmd = cmd.split() if isinstance(cmd, str) else cmd
433
- name = _cmd[0]
434
-
435
- # 3 遍历端口,依次启动进程
436
- workers = []
437
- if ports:
438
- for port in ports:
439
- cmd_with_port = cmd + [f'--port', str(port)]
440
- kwargs['port'] = port
441
- worker = self.add_program_cmd(cmd_with_port, name=f'{name}:{port}', **kwargs)
442
- workers.append(worker)
443
- else:
444
- workers = [self.add_program_cmd(cmd, name=name, **kwargs)]
445
-
446
- return workers
447
-
448
- def add_program_cmd3(self, cmd, ports=None, locations=None, *, devices=None, **kwargs):
449
- """
450
- 增强版 add_program_cmd2,支持 devices 等其他更多特殊的扩展参数的处理
451
-
452
- :param locations: URL 映射规则
453
- :param int|str|None devices: 使用的设备编号(显卡编号或 CPU)
454
- 未设置的时候,使用cpu运行
455
- :return: 返回一个包含所有启动程序的 worker 列表。
456
- """
457
- # 1 处理locations
458
- if locations:
459
- if isinstance(locations, str):
460
- locations = [locations]
461
- for i, x in enumerate(locations):
462
- if not isinstance(x, dict):
463
- locations[i] = {x: x}
464
- kwargs['locations'] = locations
465
-
466
- # 2 处理 devices 参数,设置显卡编号或使用 CPU
467
- if devices is not None:
468
- os.environ['CUDA_VISIBLE_DEVICES'] = str(devices)
469
- elif 'CUDA_VISIBLE_DEVICES' in os.environ:
470
- del os.environ['CUDA_VISIBLE_DEVICES'] # 如果没设置 devices,就清除环境变量,使用 CPU
471
-
472
- # 3 处理 ports 参数
473
- workers = self.add_program_cmd2(cmd, ports=ports, **kwargs)
474
-
475
- return workers
476
-
477
- def __2_各种添加进程的机制(self):
478
- pass
479
-
480
- def add_program_python(self, py_file, args='',
481
- ports=None, locations=None,
482
- name=None, shell=False, executer=None,
483
- **kwargs):
484
- """ 添加并启动一个Python文件作为后台程序
485
-
486
- :param str|list args:
487
- """
488
- if executer is None:
489
- executer = sys.executable
490
- cmd = [str(executer), str(py_file)]
491
- if isinstance(args, str):
492
- cmd.append(args)
493
- else:
494
- cmd += list(args)
495
- return self.add_program_cmd3(cmd, name, ports=ports, locations=locations, shell=shell, **kwargs)
496
-
497
- def add_program_python_module(self, module, args='',
498
- ports=None, locations=None,
499
- name=None, shell=False, executer=None,
500
- **kwargs):
501
- """
502
- 添加并启动一个Python模块作为后台程序
503
-
504
- :param module: 要执行的Python模块名(python -m 后面的部分)
505
- :param str|list args: 模块的参数
506
- :param name: 进程的名称,默认为模块名
507
- """
508
- if executer is None:
509
- executer = sys.executable
510
- cmd = [f'{executer}', '-m', f'{module}']
511
- if isinstance(args, str):
512
- cmd.append(args)
513
- else:
514
- cmd += list(args)
515
- if name is None:
516
- name = module
517
- return self.add_program_cmd3(cmd, ports=ports, name=name, locations=locations, shell=shell, **kwargs)
518
-
519
- def add_prog(self, prog, extcmds='',
520
- ports=None, locations=None, *,
521
- name=None, devices=None,
522
- executer=None,
523
- run=True,
524
- schedule=None,
525
- ):
526
- """
527
- :param int|list ports:
528
- :param str|list extcmds:
529
- """
530
- if locations is None:
531
- locations = f'/api/{prog.split('.')[-1]}'
532
- self.add_program_python_module(prog,
533
- extcmds,
534
- ports=ports, locations=locations,
535
- name=name, devices=devices,
536
- executer=executer,
537
- run=run,
538
- schedule=schedule,
539
- )
540
-
541
- def add_server(self, prog, ports=None, locations=None, *args, **kwargs):
542
- """ 我自己部署的服务,基本都有特定的start_server启动函数 """
543
- self.add_prog(prog, extcmds='start_server', ports=ports, locations=locations, *args, **kwargs)
544
-
545
- def add_os_command_task(self,
546
- script_content,
547
- extension=None,
548
- shell=False,
549
- run=True,
550
- name=None,
551
- schedule=None,
552
- **kwargs):
553
- """
554
- 添加一个操作系统命令或脚本(如 .bat、.sh、.ps1 等)并启动。
555
-
556
- :param script_content: 脚本的内容(字符串)
557
- :param extension: 脚本文件的扩展名(如 .bat, .sh, .ps1);如果为 None 则根据操作系统自动选择
558
- :param shell: 是否使用 shell 执行
559
- :param run: 是否立即启动
560
- :param name: 程序名称
561
- :param schedule: 定时任务配置
562
- """
563
- # 1 自动选择脚本文件扩展名
564
- if extension is None:
565
- if sys.platform == 'win32':
566
- extension = '.ps1' # Windows 默认使用 PowerShell
567
- else:
568
- extension = '.sh' # Linux 和 macOS 默认使用 Bash
569
-
570
- # 2 添加编码配置到脚本内容
571
- if extension == '.bat':
572
- # 为 .bat 文件添加 UTF-8 支持
573
- script_content = f"chcp 65001 >nul\n{script_content}"
574
- elif extension == '.ps1':
575
- # 为 .ps1 文件添加 UTF-8 支持
576
- script_content = f"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8\n" \
577
- f"[Console]::InputEncoding = [System.Text.Encoding]::UTF8\n{script_content}"
578
-
579
- # 3 创建临时脚本文件
580
- script_file = XlPath.create_tempfile_path(extension)
581
- script_file.write_text(script_content)
582
-
583
- # 4 根据文件扩展名和操作系统选择执行命令
584
- if extension == '.sh':
585
- cmd = ['bash', str(script_file)]
586
- elif extension == '.ps1':
587
- if sys.platform == 'win32':
588
- cmd = ['powershell', '-ExecutionPolicy', 'Bypass', '-File', str(script_file)]
589
- else:
590
- raise ValueError("PowerShell 脚本仅在 Windows 系统上受支持")
591
- elif extension == '.bat':
592
- cmd = [str(script_file)] # 直接运行 .bat 文件
593
- else:
594
- raise ValueError(f"不支持的脚本类型: {extension}")
595
-
596
- # 5 使用 add_program_cmd3 启动脚本
597
- return self.add_program_cmd3(
598
- cmd=cmd,
599
- name=name or f"command_{script_file.stem}",
600
- shell=shell,
601
- run=run,
602
- schedule=schedule,
603
- **kwargs
604
- )
605
-
606
- def __3_nginx相关(self):
607
- pass
608
-
609
- def get_all_locations(self):
610
- locations = defaultdict(list)
611
- for worker in self.workers:
612
- if not worker.locations:
613
- continue
614
- for x in worker.locations:
615
- for dst, src in x.items():
616
- if worker.port: # 250126周日21:02,有端口才添加
617
- locations[dst].append(f'localhost:{worker.port}{src}')
618
- return locations
619
-
620
- def configure_nginx(self, nginx_template, locations=None):
621
- if locations is None:
622
- locations = self.get_all_locations()
623
-
624
- upstreams = [] # 外部的配置
625
- servers = [nginx_template.rstrip()] # 内部的配置
626
-
627
- for dst, srcs in locations.items():
628
- if len(srcs) == 1: # 只有1个不开负载
629
- server = f'location {dst} {{\n\tproxy_pass http://{srcs[0]};\n}}\n'
630
- else: # 有多个端口功能则开负载
631
- hosts = '\n'.join([f'\tserver {src.split("/")[0]};' for src in srcs])
632
- upstream_name = 'upstream' + str(len(upstreams) + 1)
633
- upstreams.append(f'upstream {upstream_name} {{\n{hosts}\n}}\n')
634
- sub_urls = [src.split('/', maxsplit=1)[1] for src in srcs]
635
- assert len(set(sub_urls)) == 1, f'负载均衡的子url必须一致 {sub_urls}'
636
- server = f'location {dst} {{\n\tproxy_pass http://{upstream_name}/{sub_urls[0]};\n}}\n'
637
- servers.append(server)
638
-
639
- content = '\n'.join(upstreams) + '\nserver {\n' + textwrap.indent('\n'.join(servers), '\t') + '}'
640
- return content
641
-
642
- def __4_多程序管理(self):
643
- pass
644
-
645
- def count_running(self):
646
- return sum(1 for worker in self.workers if worker.is_running())
647
-
648
- def list_workers(self):
649
- """返回所有任务的状态 DataFrame"""
650
- ls = []
651
- for worker in self.workers:
652
- ls.append({
653
- 'name': worker.name,
654
- 'pid': worker.program.pid if worker.program else None,
655
- 'poll': worker.program.poll() if worker.program else None,
656
- 'args': worker.raw_cmd,
657
- 'port': worker.port,
658
- 'locations': worker.locations,
659
- })
660
- return pd.DataFrame(ls)
661
-
662
- def cleanup_finished(self, exit_code=None):
663
- """
664
- 清除已运行完的程序
665
-
666
- :param exit_code:
667
- None - 清除所有已结束的程序(默认行为)。
668
- 0 - 只清除正常结束的程序。
669
- 非0 - 只清除异常结束的程序。
670
- """
671
- new_workers = []
672
- for worker in self.workers:
673
- code = worker.program.poll()
674
- if code is None: # 进程仍在运行
675
- new_workers.append(worker)
676
- elif exit_code is None or code == exit_code:
677
- print(f'清理已结束的程序: {worker.name} (pid: {worker.program.pid}, exit code: {code})')
678
-
679
- self.workers = new_workers
680
-
681
- def stop_all(self):
682
- """ 停止所有后台程序 """
683
- # 关闭所有调度器
684
- if self.scheduler:
685
- self.scheduler.shutdown()
686
-
687
- # 停止所有单启动任务
688
- for worker in self.workers:
689
- worker.terminate()
690
- self.workers = []
691
-
692
- def proc_cmd(self, cmd):
693
- if cmd == 'kill':
694
- self.stop_all()
695
- return False
696
- elif cmd == 'count':
697
- print(f'有{self.count_running()}个程序正在运行')
698
- elif cmd.startswith('cleanup'):
699
- args = cmd.split()
700
- exit_code = int(args[1]) if len(args) > 1 else None
701
- self.cleanup_finished(exit_code)
702
- print("清理完成")
703
- elif cmd == 'list': # 列出所有程序(转df查看)
704
- df = self.list_workers()
705
- print_full_dataframe(df)
706
- return True
707
-
708
- def run_endless(self, cmd=True, wait_seconds=1, *, debug_port=None):
709
- """
710
- 一直运行,直到用户输入 kill 命令或 Ctrl+C
711
-
712
- :param bool cmd: 是否支持命令行input输入指令监控状态的模式
713
- 默认支持,但在有scheduler调度的情况不建议开启,有input阻塞其他子程的风险
714
- :param int|float wait_seconds: 每次循环之间停顿秒数,用来给其他子程等运行时间,避免阻塞
715
- :param int debug_port: 是否要开一个后端服务,支持查询程序运行状态
716
-
717
- poll:
718
- 如果进程仍在运行,poll()方法返回None。
719
- 如果进程已经结束,poll()方法返回进程的退出码(exit code)。
720
- 如果进程正常结束,退出码通常为0。
721
- 如果进程异常结束,退出码通常是一个非零值,表示异常的类型或错误码。
722
- """
723
- if self.scheduler: # 如果有定时任务,启动调度器
724
- self.scheduler.start()
725
-
726
- port = debug_port
727
- if port:
728
- dashboard = LauncherDashboard(self)
729
- threading.Thread(target=lambda: dashboard.run(port), daemon=True).start()
730
-
731
- try:
732
- while True:
733
- if cmd:
734
- _cmd = input(">>> ")
735
- if not self.proc_cmd(_cmd):
736
- break
737
- time.sleep(wait_seconds)
738
- except KeyboardInterrupt:
739
- print("\n检测到 Ctrl+C,正在终止所有程序...")
740
- self.stop_all()
741
- print("所有程序已终止,退出。")
742
-
743
-
744
- @deprecated(reason='已改名ProgramWorker')
745
- class ProcessWorker(ProgramWorker):
746
- pass
747
-
748
-
749
- @deprecated(reason='已改名MultiProgramLauncher')
750
- class MultiProcessLauncher(MultiProgramLauncher):
751
- pass
752
-
753
-
754
- class LauncherDashboard:
755
- def __init__(self, launcher):
756
- self.launcher = launcher
757
- self.app = FastAPI()
758
- self.setup_routes()
759
-
760
- def setup_routes(self):
761
- @self.app.get("/", response_class=PlainTextResponse)
762
- async def home():
763
- """主页,展示所有任务状态"""
764
- df = self.launcher.list_workers()
765
- return self.render_text(df)
766
-
767
- @self.app.get("/count", response_class=PlainTextResponse)
768
- async def get_count():
769
- """返回正在运行的任务数量"""
770
- count = self.launcher.count_running()
771
- return f"正在运行的任务数量: {count}\n"
772
-
773
- @self.app.post("/stop", response_class=PlainTextResponse)
774
- async def stop_all():
775
- """停止所有任务"""
776
- self.launcher.stop_all()
777
- return "所有任务已停止。\n"
778
-
779
- @self.app.post("/cleanup", response_class=PlainTextResponse)
780
- async def cleanup(exit_code: int = None):
781
- """清理已完成的任务"""
782
- self.launcher.cleanup_finished(exit_code)
783
- return "清理完成。\n"
784
-
785
- def render_text(self, df):
786
- """生成纯文本格式的任务状态"""
787
- if df.empty:
788
- return "没有任务正在运行。\n"
789
-
790
- # 调整表头以显示新的属性:port 和 locations
791
- output = ["当前任务状态:\n"]
792
- output.append(f"{'编号':<5} {'名称':<15} {'PID':<10} {'状态':<10} {'端口':<10} {'位置':<20} {'命令'}")
793
- output.append("-" * 120)
794
-
795
- for idx, (_, row) in enumerate(df.iterrows(), start=1):
796
- name = row['name']
797
-
798
- # 更鲁棒地处理 pid,考虑 NaN 情况
799
- pid = "N/A" if pd.isna(row['pid']) else int(row['pid'])
800
-
801
- # 获取任务状态
802
- status = "运行中" if row['poll'] is None else "已结束"
803
-
804
- # 处理 args,确保路径展示更清晰
805
- args = [a for a in row['args'] if a] if isinstance(row['args'], list) else []
806
- args_str = "[" + ", ".join(
807
- repr(arg).replace("\\\\", "\\") if '\\' in arg else repr(arg) for arg in args
808
- ) + "]"
809
-
810
- # 处理 port 和 locations
811
- port = row['port'] if not pd.isna(row['port']) else "N/A"
812
- locations = str(row['locations'])
813
-
814
- # 格式化输出
815
- output.append(f"{idx:<5} {name:<15} {pid:<10} {status:<10} {port:<10} {locations:<20} {args_str}")
816
-
817
- output.append("\n")
818
- return "\n".join(output)
819
-
820
- def run(self, port=8080):
821
- """启动 FastAPI 服务"""
822
- import uvicorn
823
- uvicorn.run(self.app, host="0.0.0.0", port=port, log_level="warning")
824
-
825
-
826
- def __3_装饰器工具():
827
- pass
828
-
829
-
830
- def support_multi_processes_hyx(default_processes=1):
831
- """ 对函数进行扩展,支持并发多进程运行
832
- 增加重跑
833
- 注意被装饰的函数,需要支持 process_count、process_id 两个参数,来获得总进程数,当前进程id的信息
834
- """
835
-
836
- def decorator(func):
837
-
838
- def wrapper(*args, **kwargs):
839
- process_count = int(kwargs.pop('process_count', default_processes))
840
- process_id = kwargs.pop('process_id', None)
841
- shell = kwargs.pop('shell', False)
842
-
843
- if process_count == 1 or process_id is not None:
844
- if process_id is None:
845
- return func(*args, **kwargs)
846
- else:
847
- return func(*args, **kwargs, process_count=process_count, process_id=int(process_id))
848
- else:
849
- mpl = MultiProcessLauncher()
850
- for i in range(int(process_count)):
851
- if isinstance(process_id, int) and i != process_id:
852
- continue
853
-
854
- '''
855
- sys.argv[0] 为 /Users/youx/NutstoreCloudBridge/slns/xlproject/xlproject/m2404ragdata/b清洗/hyx240806统计图表数.py
856
- 将其转化为 xlproject.m2404ragdata.b清洗.hyx240806统计图表数
857
- '''
858
- header = 'xlproject.code4101'
859
-
860
- root_directory_name = 'xlproject'
861
- occurrence = 2
862
- path = sys.argv[0]
863
-
864
- # 查找第 occurrence 次出现的 root_directory_name 位置
865
- positions = [i for i in range(len(path)) if path.startswith(root_directory_name, i)]
866
- if len(positions) < occurrence:
867
- raise ValueError(
868
- f"Path does not contain {occurrence} occurrences of the root directory name: {root_directory_name}")
869
-
870
- # 提取并转换为模块名称
871
- index = positions[occurrence - 1]
872
- module_name = os.path.splitext(path[index:])[0].replace(os.path.sep, '.')
873
-
874
- # todo 这样使用有个坑,process_count、process_id都是以str类型传入的,开发者下游使用容易出问题
875
- cmds = [
876
- 'run_python_module',
877
- '--wait_mode',
878
- '60',
879
- module_name,
880
- func.__name__,
881
- '--process_count', str(process_count),
882
- '--process_id', str(i)
883
- ]
884
- cmds.extend(map(str, args)) # 添加位置参数
885
- for k, v in kwargs.items():
886
- cmds.append(f'--{k}') # 添加关键字参数的键
887
- cmds.append(str(v)) # 添加关键字参数的值
888
-
889
- mpl.add_program_python_module(header, cmds, shell=shell)
890
- mpl.run_endless()
891
-
892
- return wrapper
893
-
894
- return decorator
895
-
896
-
897
- def support_multi_processes(default_processes=1):
898
- """ 对函数进行扩展,支持并发多进程运行
899
-
900
- 注意被装饰的函数,需要支持 process_count、process_id 两个参数,来获得总进程数,当前进程id的信息
901
- """
902
-
903
- def decorator(func):
904
-
905
- def wrapper(*args, **kwargs):
906
- process_count = int(kwargs.pop('process_count', default_processes))
907
- process_id = kwargs.pop('process_id', None)
908
- shell = kwargs.pop('shell', False)
909
-
910
- if process_count == 1 or process_id is not None:
911
- if process_id is None:
912
- return func(*args, **kwargs)
913
- else:
914
- return func(*args, **kwargs, process_count=process_count, process_id=int(process_id))
915
- else:
916
- mpl = MultiProcessLauncher()
917
- for i in range(int(process_count)):
918
- if isinstance(process_id, int) and i != process_id:
919
- continue
920
-
921
- # todo 这样使用有个坑,process_count、process_id都是以str类型传入的,开发者下游使用容易出问题
922
- cmds = [func.__name__,
923
- '--process_count', str(process_count),
924
- '--process_id', str(i)
925
- ]
926
- cmds.extend(map(str, args)) # 添加位置参数
927
- for k, v in kwargs.items():
928
- cmds.append(f'--{k}') # 添加关键字参数的键
929
- cmds.append(str(v)) # 添加关键字参数的值
930
-
931
- mpl.add_program_python(sys.argv[1], cmds, shell=shell)
932
- mpl.run_endless()
933
-
934
- return wrapper
935
-
936
- return decorator
937
-
938
-
939
- if __name__ == '__main__':
940
- pass
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # @Author : 陈坤泽
4
+ # @Email : 877362867@qq.com
5
+ # @Date : 2024/11/12
6
+
7
+ from collections import defaultdict
8
+ from types import SimpleNamespace
9
+ from unittest.mock import Mock
10
+ import ctypes
11
+ import datetime
12
+ import os
13
+ import socketserver
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ import textwrap
18
+ import threading
19
+
20
+ from deprecated import deprecated
21
+ from loguru import logger
22
+ from croniter import croniter
23
+ import pandas as pd
24
+
25
+ from fastapi import FastAPI
26
+ from fastapi.responses import PlainTextResponse
27
+
28
+ from pyxllib.prog.specialist import parse_datetime
29
+ from pyxllib.algo.stat import print_full_dataframe
30
+ from pyxllib.file.specialist import XlPath
31
+
32
+
33
+ def __1_定时工具():
34
+ pass
35
+
36
+
37
+ class SchedulerUtils:
38
+ @classmethod
39
+ def calculate_future_time(cls, start_time, wait_seconds):
40
+ """ 计算延迟时间
41
+
42
+ :param datetime start_time: 开始时间
43
+ :param int wait_seconds: 等待秒数
44
+ todo 先只支持秒数这种标准秒数,后续可以考虑支持更多智能的"1小时"等这种解析
45
+ """
46
+ return start_time + datetime.timedelta(seconds=wait_seconds)
47
+
48
+ @classmethod
49
+ def calculate_next_cron_time(cls, cron_tag, base_time=None):
50
+ """ 使用crontab标记的运行周期,然后计算相对当前时间,下一次要启动运行的时间
51
+
52
+ :param str cron_tag: 自定义的cron标记,跟asp的差不多,但星期几部分做了调整
53
+ 30 2 * * 1: 这部分是时间和日期的设定,具体含义如下:
54
+ 30: 表示分钟,即每小时的第 30 分钟。
55
+ 2: 表示小时,即凌晨 2 点。
56
+ 第三个星号 *: 表示日,这里的星号意味着每天。
57
+ 第四个星号 *: 表示月份,星号同样表示每个月。
58
+ 1: 表示星期中的日子,这里的 1 代表星期一,7表示星期日。不能写1~7以外的值。
59
+ :param datetime base_time: 基于哪个时间点计算下次时间
60
+ """
61
+
62
+ # 如果没有提供基准时间,则使用当前时间
63
+ if base_time is None:
64
+ base_time = datetime.datetime.now()
65
+ # 初始化 croniter 对象
66
+ cron = croniter(cron_tag, base_time)
67
+ # 计算下一次运行时间
68
+ next_time = cron.get_next(datetime.datetime)
69
+ return next_time
70
+
71
+ @classmethod
72
+ def wait_until_time(cls, dst_time):
73
+ """
74
+ :param datetime dst_time: 一直等待到目标时间
75
+ 期间可以用time.sleep进行等待
76
+ """
77
+ # 一般来说,只要计算一轮待等待秒数就行。但是time.sleep机制好像不一定准确的,所以使用无限循环重试会更好。
78
+ while True:
79
+ # 先计算当前时间和目标时间的相差秒数
80
+ wait_seconds = (dst_time - datetime.datetime.now()).total_seconds()
81
+ if wait_seconds <= 0:
82
+ break
83
+ time.sleep(max(1, wait_seconds)) # 最少等待1秒
84
+
85
+ @classmethod
86
+ def smart_wait(cls, start_time, end_time, wait_tag, print_mode=0):
87
+ """ 智能等待,一般用在对进程的管理重启上
88
+
89
+ :param datetime start_time: 程序启动的时间
90
+ :param datetime end_time: 程序结束的时间
91
+ :param str|float|int wait_tag: 等待标记
92
+ str,按crontab解析
93
+ 在end_time后满足条件的下次时间重启
94
+ int|float,表示等待的秒数
95
+ 正值是end_time往后等待,负值是start_time开始计算下次时间。
96
+ 比如1点开始的程序,等待半小时,但是首次运行到2点才结束
97
+ 那么正值就是2:30再下次运行
98
+ 但是负值表示1:30就要运行,已经错过了,马上2点结束就立即启动复跑
99
+ """
100
+ # 1 尝试把wait_tag转成数值
101
+ try:
102
+ wait_tag = float(wait_tag)
103
+ except ValueError: # 转不成也没关系
104
+ pass
105
+
106
+ if start_time is None:
107
+ start_time = datetime.datetime.now()
108
+ if end_time is None:
109
+ end_time = datetime.datetime.now()
110
+
111
+ # 2 计算下一次启动时间
112
+ if isinstance(wait_tag, str):
113
+ # 按照crontab解析
114
+ next_time = cls.calculate_next_cron_time(wait_tag, end_time)
115
+ elif wait_tag >= 0:
116
+ # 正值则是从end_time开始往后等待
117
+ next_time = cls.calculate_future_time(end_time, wait_tag)
118
+ elif wait_tag < 0:
119
+ # 负值则是从start_time开始往前等待
120
+ next_time = cls.calculate_future_time(start_time, wait_tag)
121
+ else:
122
+ raise ValueError
123
+
124
+ if print_mode:
125
+ print(f'等待到时间{next_time}...')
126
+
127
+ cls.wait_until_time(next_time)
128
+
129
+
130
+ def __2_程序管理():
131
+ pass
132
+
133
+
134
+ def find_free_ports(count=1):
135
+ """ 随机获得可用端口
136
+
137
+ :param count: 需要的端口数量(会保证给出的端口号不重复)
138
+ :return: list
139
+ """
140
+ ports = set()
141
+ while len(ports) < count:
142
+ with socketserver.TCPServer(("localhost", 0), None) as s:
143
+ ports.add(s.server_address[1])
144
+
145
+ return list(ports)
146
+
147
+
148
+ class ProgramWorker(SimpleNamespace):
149
+ """
150
+ 代表一个单独的程序(进程),基于 SimpleNamespace 实现。
151
+ """
152
+
153
+ def __init__(self, name,
154
+ cmd,
155
+ shell=False,
156
+ run=True,
157
+ port=None,
158
+ locations=None,
159
+ raw_cmd=None,
160
+ **attrs):
161
+ """
162
+ :param name: 程序昵称
163
+ :param program: 程序对象
164
+ :param port: 是否有执行所在端口
165
+ :param locations: 是否有url地址映射,一般用于nginx等配置
166
+ """
167
+ super().__init__(**attrs)
168
+
169
+ # 执行程序需要使用的参数
170
+ self.cmd = cmd
171
+ self.shell = shell
172
+ self.run = run
173
+
174
+ # 关键参数
175
+ self.name = name
176
+ self.program = None
177
+ self.raw_cmd = raw_cmd
178
+
179
+ # 这两个是比较特别的属性,在我的工程框架中常用
180
+ self.port = port
181
+ self.locations = locations
182
+
183
+ # 特殊调度模式,需要用到程序启动、结束时间
184
+ self.last_start_time = None
185
+ self.last_end_time = None
186
+
187
+ def _set_pdeathsig(self, sig=None):
188
+ """ 在主服务退出时,这些程序也会全部自动关闭 """
189
+
190
+ def callable():
191
+ import signal
192
+ sig2 = signal.SIGTERM if sig is None else sig
193
+ libc = ctypes.CDLL("libc.so.6")
194
+ return libc.prctl(1, sig2)
195
+
196
+ if sys.platform == 'win32':
197
+ # windows系统暂设为空
198
+ return None
199
+ else:
200
+ return callable
201
+
202
+ def lanuch(self):
203
+ """ 启动程序 """
204
+ self.last_start_time = datetime.datetime.now()
205
+ self.last_end_time = None
206
+
207
+ if not self.run:
208
+ # 如果不需要立即启动,则返回一个 Mock 对象
209
+ proc = Mock()
210
+ proc.pid = None
211
+ proc.poll.return_value = 'tag'
212
+ self.program = proc
213
+ return self.program
214
+
215
+ kwargs = {}
216
+ for name in ['stdin', 'stdout', 'stderr']:
217
+ if hasattr(self, name):
218
+ kwargs[name] = getattr(self, name)
219
+
220
+ if sys.platform == 'win32':
221
+ self.program = subprocess.Popen(self.cmd, shell=self.shell, **kwargs)
222
+ else:
223
+ self.program = subprocess.Popen(self.cmd, shell=self.shell, **kwargs,
224
+ preexec_fn=self._set_pdeathsig())
225
+
226
+ return self.program
227
+
228
+ def terminate(self):
229
+ """
230
+ 比较优雅结束进程的方法
231
+ """
232
+ if self.program is not None:
233
+ self.program.terminate()
234
+ if self.last_end_time is None:
235
+ self.last_end_time = datetime.datetime.now()
236
+
237
+ def kill(self):
238
+ """
239
+ 有时候需要强硬的kill方法来结束进程
240
+ """
241
+ if self.program is not None:
242
+ self.program.kill()
243
+ if self.last_end_time is None:
244
+ self.last_end_time = datetime.datetime.now()
245
+
246
+ def is_running(self):
247
+ """ 检查程序是否在运行 """
248
+ status = self.program is not None and self.program.poll() is None
249
+ if not status and self.last_end_time is None: # 检测到程序运行结束,则标记下
250
+ self.last_end_time = datetime.datetime.now()
251
+ return status
252
+
253
+
254
+ class MultiProgramLauncher:
255
+ """
256
+ 管理多个程序的启动与终止
257
+ """
258
+
259
+ def __init__(self):
260
+ self.workers = []
261
+ self.scheduler = None
262
+
263
+ def __1_进场管理的核心底层函数(self):
264
+ pass
265
+
266
+ def init_scheduler(self):
267
+ from apscheduler.schedulers.background import BackgroundScheduler
268
+ # from apscheduler.schedulers.asyncio import AsyncIOScheduler
269
+
270
+ if self.scheduler is None:
271
+ self.scheduler = BackgroundScheduler()
272
+
273
+ return self.scheduler
274
+
275
+ def worker_add_schedule(self, worker, schedule=None, misfire_grace_time=None):
276
+ """ 将程序添加为定时任务
277
+
278
+ :param int|float|str|list schedule: 定时任务配置,如果提供则添加到 APScheduler 调度器
279
+ int/float: 每隔多少秒执行一次,可能同时有多个实例存在
280
+ tuple(value1, value2): 只允许单实例运行,这里记录的是上次实例结束后,等待多久再开启下次实例
281
+ 此时第1个数值,正数表示等待上一次运行结束后,下次开始运行前的等待时间
282
+ 负数表示是在上次启动后,等待多久就可以运行下一次
283
+ 但下一次一定会在上一次运行结束后再续上
284
+ 第2个参数,为了实现这套功能,其实需要一个监视器不断监控程序的运行状态
285
+ 第2个参数是监控器检测的频率秒数
286
+ str: cron 表达式,支持 5 或 6 个字段
287
+ list[datetime]: 在指定时间节点列表运行
288
+ 值只要是可以解析为datetime类型的都可以,底层使用特殊的解析器
289
+ """
290
+ from datetime import datetime, timedelta
291
+
292
+ def task():
293
+ """ 启动任务并更新状态 """
294
+ # todo schedule不一定都是单实例阻塞情景的需求,以后有需要可以扩展支持多实例同时存在的非阻塞模式
295
+ if worker.is_running():
296
+ logger.warning(f'由于程序"{name}"在上一次周期还没运行完,新周期不重复启动')
297
+ else:
298
+ worker.lanuch()
299
+
300
+ self.init_scheduler()
301
+
302
+ name = worker.name
303
+
304
+ # 处理 schedule 类型
305
+ if isinstance(schedule, (int, float)):
306
+ # 如果是单一数值,按固定间隔执行,不考虑任务是否完成
307
+ self.scheduler.add_job(task, 'interval', seconds=abs(schedule))
308
+ logger.info(f"已添加定时任务:{name},监测频率:{schedule} 秒")
309
+
310
+ elif isinstance(schedule, tuple) and len(schedule) == 2:
311
+ wait_time, monitor_frequency = schedule
312
+
313
+ def interval_task():
314
+ # 如果还没有启动过任务,直接启动
315
+ if worker.last_start_time is None:
316
+ worker.lanuch()
317
+ return
318
+
319
+ # 如果任务正在运行,则不启动新任务
320
+ if worker.is_running():
321
+ return
322
+
323
+ # 计算下次启动时间
324
+ if wait_time < 0: # 基于上次启动时间
325
+ next_run = worker.last_start_time + timedelta(seconds=abs(wait_time))
326
+ else: # 基于上次结束时间
327
+ next_run = worker.last_end_time + timedelta(seconds=wait_time)
328
+
329
+ # 看现在是否需要重启
330
+ if datetime.now() >= next_run:
331
+ worker.lanuch()
332
+
333
+ # 以 monitor_frequency 作为定时检查的间隔
334
+ self.scheduler.add_job(interval_task, 'interval', seconds=monitor_frequency)
335
+ logger.info(f"已添加定时任务:{name},等待时间: {wait_time} 秒,监测频率: {monitor_frequency} 秒")
336
+
337
+ elif isinstance(schedule, str):
338
+ from apscheduler.triggers.cron import CronTrigger
339
+ cron_parts = schedule.split()
340
+ # 如果是 5 个字段,则补齐 "秒" 字段为 '0'
341
+ if len(cron_parts) == 5:
342
+ cron_parts.insert(0, '0')
343
+ # 检查是否为有效的 6 字段 cron 表达式
344
+ if len(cron_parts) == 6:
345
+ # 统一处理为 6 字段格式
346
+ # 把我自定义的cron的星期标记转换为aps的星期标记。前者用1234567,后者用0123456表示星期一到星期日
347
+ x = cron_parts[5]
348
+ if x != '*': # 写0或7都表示周日
349
+ if x == '0':
350
+ x = '7'
351
+ x = (int(x) - 1)
352
+ self.scheduler.add_job(
353
+ task,
354
+ CronTrigger(
355
+ second=cron_parts[0],
356
+ minute=cron_parts[1],
357
+ hour=cron_parts[2],
358
+ day=cron_parts[3],
359
+ month=cron_parts[4],
360
+ day_of_week=x,
361
+ ),
362
+ # 检测的时候有概率错过了精确时间点。但一般不论延迟了多久,都要补运行上。
363
+ misfire_grace_time=misfire_grace_time,
364
+ )
365
+ logger.info(f"已添加定时任务:{name},触发器: cron,表达式: {schedule}")
366
+ else:
367
+ logger.warning(f"无效的 cron 表达式:{schedule}")
368
+
369
+ elif isinstance(schedule, list):
370
+ # 支持 list[datetime] 格式
371
+ for run_time in schedule:
372
+ run_time = parse_datetime(run_time)
373
+ self.scheduler.add_job(task, 'date', run_date=run_time)
374
+ logger.info(f"已添加定时任务:{name},触发器: dates,运行时间: {schedule}")
375
+
376
+ else:
377
+ logger.warning(f"无效的调度格式,跳过任务:{name}")
378
+
379
+ def add_program_cmd(self,
380
+ cmd,
381
+ name=None,
382
+ shell=False,
383
+ run=True,
384
+ schedule=None,
385
+ **attrs):
386
+ """
387
+ 启动一个程序,或仅存储任务,并添加进管理列表
388
+
389
+ :param cmd: 启动程序的命令
390
+ :param name: 程序名称,如果未提供则从cmd中自动获取
391
+ :param shell:
392
+ False,(优先推荐)直接跟系统交互,此时cmd应该输入数组格式
393
+ True,启用shell进行交互操作,这种情况会更适合管道等模式的处理,此时cmd应该输入字符串格式
394
+ :param int|float|str|list schedule: 定时任务配置,如果提供则添加到 APScheduler 调度器
395
+ :param run: 如果为True,则立即启动程序;否则仅存储任务信息
396
+ :param attrs: 其他需要传递给ProgramWorker的参数
397
+ :return: 返回一个ProgramWorker实例,表示启动的程序或存储的任务
398
+ """
399
+ # 1 如果未显式传入name,自动从cmd中获取程序名称
400
+ # logger.info(cmd)
401
+ if name is None:
402
+ _cmd = cmd.split() if isinstance(cmd, str) else cmd
403
+ name = _cmd[0]
404
+
405
+ # 2 创建 ProgramWorker 实例
406
+ worker = ProgramWorker(name, cmd, shell, run, raw_cmd=cmd, **attrs)
407
+ self.workers.append(worker)
408
+
409
+ # 3 如果有 schedule 配置,添加调度任务;否则就是正常的启动任务
410
+ if schedule:
411
+ self.worker_add_schedule(worker, schedule=schedule, misfire_grace_time=attrs.get('misfire_grace_time'))
412
+ else:
413
+ worker.lanuch()
414
+
415
+ return worker
416
+
417
+ def add_program_cmd2(self, cmd, ports=1, name=None, **kwargs):
418
+ """
419
+ 增强版 add_program_cmd,支持 ports 的处理
420
+
421
+ :param ports:
422
+ int 表示要开启的进程数,端口号随机生成
423
+ list 表示指定的端口号
424
+ None 不做特殊配置
425
+ """
426
+ # 1 处理 ports 参数,找到空闲端口或使用指定端口
427
+ if isinstance(ports, int):
428
+ ports = find_free_ports(ports)
429
+
430
+ # 2 处理 locations 参数,自动设置默认 URL 映射
431
+ if name is None:
432
+ _cmd = cmd.split() if isinstance(cmd, str) else cmd
433
+ name = _cmd[0]
434
+
435
+ # 3 遍历端口,依次启动进程
436
+ workers = []
437
+ if ports:
438
+ for port in ports:
439
+ cmd_with_port = cmd + [f'--port', str(port)]
440
+ kwargs['port'] = port
441
+ worker = self.add_program_cmd(cmd_with_port, name=f'{name}:{port}', **kwargs)
442
+ workers.append(worker)
443
+ else:
444
+ workers = [self.add_program_cmd(cmd, name=name, **kwargs)]
445
+
446
+ return workers
447
+
448
+ def add_program_cmd3(self, cmd, ports=None, locations=None, *, devices=None, **kwargs):
449
+ """
450
+ 增强版 add_program_cmd2,支持 devices 等其他更多特殊的扩展参数的处理
451
+
452
+ :param locations: URL 映射规则
453
+ :param int|str|None devices: 使用的设备编号(显卡编号或 CPU)
454
+ 未设置的时候,使用cpu运行
455
+ :return: 返回一个包含所有启动程序的 worker 列表。
456
+ """
457
+ # 1 处理locations
458
+ if locations:
459
+ if isinstance(locations, str):
460
+ locations = [locations]
461
+ for i, x in enumerate(locations):
462
+ if not isinstance(x, dict):
463
+ locations[i] = {x: x}
464
+ kwargs['locations'] = locations
465
+
466
+ # 2 处理 devices 参数,设置显卡编号或使用 CPU
467
+ if devices is not None:
468
+ os.environ['CUDA_VISIBLE_DEVICES'] = str(devices)
469
+ elif 'CUDA_VISIBLE_DEVICES' in os.environ:
470
+ del os.environ['CUDA_VISIBLE_DEVICES'] # 如果没设置 devices,就清除环境变量,使用 CPU
471
+
472
+ # 3 处理 ports 参数
473
+ workers = self.add_program_cmd2(cmd, ports=ports, **kwargs)
474
+
475
+ return workers
476
+
477
+ def __2_各种添加进程的机制(self):
478
+ pass
479
+
480
+ def add_program_python(self, py_file, args='',
481
+ ports=None, locations=None,
482
+ name=None, shell=False, executer=None,
483
+ **kwargs):
484
+ """ 添加并启动一个Python文件作为后台程序
485
+
486
+ :param str|list args:
487
+ """
488
+ if executer is None:
489
+ executer = sys.executable
490
+ cmd = [str(executer), str(py_file)]
491
+ if isinstance(args, str):
492
+ cmd.append(args)
493
+ else:
494
+ cmd += list(args)
495
+ return self.add_program_cmd3(cmd, name, ports=ports, locations=locations, shell=shell, **kwargs)
496
+
497
+ def add_program_python_module(self, module, args='',
498
+ ports=None, locations=None,
499
+ name=None, shell=False, executer=None,
500
+ **kwargs):
501
+ """
502
+ 添加并启动一个Python模块作为后台程序
503
+
504
+ :param module: 要执行的Python模块名(python -m 后面的部分)
505
+ :param str|list args: 模块的参数
506
+ :param name: 进程的名称,默认为模块名
507
+ """
508
+ if executer is None:
509
+ executer = sys.executable
510
+ cmd = [f'{executer}', '-m', f'{module}']
511
+ if isinstance(args, str):
512
+ cmd.append(args)
513
+ else:
514
+ cmd += list(args)
515
+ if name is None:
516
+ name = module
517
+ return self.add_program_cmd3(cmd, ports=ports, name=name, locations=locations, shell=shell, **kwargs)
518
+
519
+ def add_prog(self, prog, extcmds='',
520
+ ports=None, locations=None, *,
521
+ name=None, devices=None,
522
+ executer=None,
523
+ run=True,
524
+ schedule=None,
525
+ ):
526
+ """
527
+ :param int|list ports:
528
+ :param str|list extcmds:
529
+ """
530
+ if locations is None:
531
+ locations = f'/api/{prog.split('.')[-1]}'
532
+ self.add_program_python_module(prog,
533
+ extcmds,
534
+ ports=ports, locations=locations,
535
+ name=name, devices=devices,
536
+ executer=executer,
537
+ run=run,
538
+ schedule=schedule,
539
+ )
540
+
541
+ def add_server(self, prog, ports=None, locations=None, *args, **kwargs):
542
+ """ 我自己部署的服务,基本都有特定的start_server启动函数 """
543
+ self.add_prog(prog, extcmds='start_server', ports=ports, locations=locations, *args, **kwargs)
544
+
545
+ def add_os_command_task(self,
546
+ script_content,
547
+ extension=None,
548
+ shell=False,
549
+ run=True,
550
+ name=None,
551
+ schedule=None,
552
+ **kwargs):
553
+ """
554
+ 添加一个操作系统命令或脚本(如 .bat、.sh、.ps1 等)并启动。
555
+
556
+ :param script_content: 脚本的内容(字符串)
557
+ :param extension: 脚本文件的扩展名(如 .bat, .sh, .ps1);如果为 None 则根据操作系统自动选择
558
+ :param shell: 是否使用 shell 执行
559
+ :param run: 是否立即启动
560
+ :param name: 程序名称
561
+ :param schedule: 定时任务配置
562
+ """
563
+ # 1 自动选择脚本文件扩展名
564
+ if extension is None:
565
+ if sys.platform == 'win32':
566
+ extension = '.ps1' # Windows 默认使用 PowerShell
567
+ else:
568
+ extension = '.sh' # Linux 和 macOS 默认使用 Bash
569
+
570
+ # 2 添加编码配置到脚本内容
571
+ if extension == '.bat':
572
+ # 为 .bat 文件添加 UTF-8 支持
573
+ script_content = f"chcp 65001 >nul\n{script_content}"
574
+ elif extension == '.ps1':
575
+ # 为 .ps1 文件添加 UTF-8 支持
576
+ script_content = f"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8\n" \
577
+ f"[Console]::InputEncoding = [System.Text.Encoding]::UTF8\n{script_content}"
578
+
579
+ # 3 创建临时脚本文件
580
+ script_file = XlPath.create_tempfile_path(extension)
581
+ script_file.write_text(script_content)
582
+
583
+ # 4 根据文件扩展名和操作系统选择执行命令
584
+ if extension == '.sh':
585
+ cmd = ['bash', str(script_file)]
586
+ elif extension == '.ps1':
587
+ if sys.platform == 'win32':
588
+ cmd = ['powershell', '-ExecutionPolicy', 'Bypass', '-File', str(script_file)]
589
+ else:
590
+ raise ValueError("PowerShell 脚本仅在 Windows 系统上受支持")
591
+ elif extension == '.bat':
592
+ cmd = [str(script_file)] # 直接运行 .bat 文件
593
+ else:
594
+ raise ValueError(f"不支持的脚本类型: {extension}")
595
+
596
+ # 5 使用 add_program_cmd3 启动脚本
597
+ return self.add_program_cmd3(
598
+ cmd=cmd,
599
+ name=name or f"command_{script_file.stem}",
600
+ shell=shell,
601
+ run=run,
602
+ schedule=schedule,
603
+ **kwargs
604
+ )
605
+
606
+ def __3_nginx相关(self):
607
+ pass
608
+
609
+ def get_all_locations(self):
610
+ locations = defaultdict(list)
611
+ for worker in self.workers:
612
+ if not worker.locations:
613
+ continue
614
+ for x in worker.locations:
615
+ for dst, src in x.items():
616
+ if worker.port: # 250126周日21:02,有端口才添加
617
+ locations[dst].append(f'localhost:{worker.port}{src}')
618
+ return locations
619
+
620
+ def configure_nginx(self, nginx_template, locations=None):
621
+ if locations is None:
622
+ locations = self.get_all_locations()
623
+
624
+ upstreams = [] # 外部的配置
625
+ servers = [nginx_template.rstrip()] # 内部的配置
626
+
627
+ for dst, srcs in locations.items():
628
+ if len(srcs) == 1: # 只有1个不开负载
629
+ server = f'location {dst} {{\n\tproxy_pass http://{srcs[0]};\n}}\n'
630
+ else: # 有多个端口功能则开负载
631
+ hosts = '\n'.join([f'\tserver {src.split("/")[0]};' for src in srcs])
632
+ upstream_name = 'upstream' + str(len(upstreams) + 1)
633
+ upstreams.append(f'upstream {upstream_name} {{\n{hosts}\n}}\n')
634
+ sub_urls = [src.split('/', maxsplit=1)[1] for src in srcs]
635
+ assert len(set(sub_urls)) == 1, f'负载均衡的子url必须一致 {sub_urls}'
636
+ server = f'location {dst} {{\n\tproxy_pass http://{upstream_name}/{sub_urls[0]};\n}}\n'
637
+ servers.append(server)
638
+
639
+ content = '\n'.join(upstreams) + '\nserver {\n' + textwrap.indent('\n'.join(servers), '\t') + '}'
640
+ return content
641
+
642
+ def __4_多程序管理(self):
643
+ pass
644
+
645
+ def count_running(self):
646
+ return sum(1 for worker in self.workers if worker.is_running())
647
+
648
+ def list_workers(self):
649
+ """返回所有任务的状态 DataFrame"""
650
+ ls = []
651
+ for worker in self.workers:
652
+ ls.append({
653
+ 'name': worker.name,
654
+ 'pid': worker.program.pid if worker.program else None,
655
+ 'poll': worker.program.poll() if worker.program else None,
656
+ 'args': worker.raw_cmd,
657
+ 'port': worker.port,
658
+ 'locations': worker.locations,
659
+ })
660
+ return pd.DataFrame(ls)
661
+
662
+ def cleanup_finished(self, exit_code=None):
663
+ """
664
+ 清除已运行完的程序
665
+
666
+ :param exit_code:
667
+ None - 清除所有已结束的程序(默认行为)。
668
+ 0 - 只清除正常结束的程序。
669
+ 非0 - 只清除异常结束的程序。
670
+ """
671
+ new_workers = []
672
+ for worker in self.workers:
673
+ code = worker.program.poll()
674
+ if code is None: # 进程仍在运行
675
+ new_workers.append(worker)
676
+ elif exit_code is None or code == exit_code:
677
+ print(f'清理已结束的程序: {worker.name} (pid: {worker.program.pid}, exit code: {code})')
678
+
679
+ self.workers = new_workers
680
+
681
+ def stop_all(self):
682
+ """ 停止所有后台程序 """
683
+ # 关闭所有调度器
684
+ if self.scheduler:
685
+ self.scheduler.shutdown()
686
+
687
+ # 停止所有单启动任务
688
+ for worker in self.workers:
689
+ worker.terminate()
690
+ self.workers = []
691
+
692
+ def proc_cmd(self, cmd):
693
+ if cmd == 'kill':
694
+ self.stop_all()
695
+ return False
696
+ elif cmd == 'count':
697
+ print(f'有{self.count_running()}个程序正在运行')
698
+ elif cmd.startswith('cleanup'):
699
+ args = cmd.split()
700
+ exit_code = int(args[1]) if len(args) > 1 else None
701
+ self.cleanup_finished(exit_code)
702
+ print("清理完成")
703
+ elif cmd == 'list': # 列出所有程序(转df查看)
704
+ df = self.list_workers()
705
+ print_full_dataframe(df)
706
+ return True
707
+
708
+ def run_endless(self, cmd=True, wait_seconds=1, *, debug_port=None):
709
+ """
710
+ 一直运行,直到用户输入 kill 命令或 Ctrl+C
711
+
712
+ :param bool cmd: 是否支持命令行input输入指令监控状态的模式
713
+ 默认支持,但在有scheduler调度的情况不建议开启,有input阻塞其他子程的风险
714
+ :param int|float wait_seconds: 每次循环之间停顿秒数,用来给其他子程等运行时间,避免阻塞
715
+ :param int debug_port: 是否要开一个后端服务,支持查询程序运行状态
716
+
717
+ poll:
718
+ 如果进程仍在运行,poll()方法返回None。
719
+ 如果进程已经结束,poll()方法返回进程的退出码(exit code)。
720
+ 如果进程正常结束,退出码通常为0。
721
+ 如果进程异常结束,退出码通常是一个非零值,表示异常的类型或错误码。
722
+ """
723
+ if self.scheduler: # 如果有定时任务,启动调度器
724
+ self.scheduler.start()
725
+
726
+ port = debug_port
727
+ if port:
728
+ dashboard = LauncherDashboard(self)
729
+ threading.Thread(target=lambda: dashboard.run(port), daemon=True).start()
730
+
731
+ try:
732
+ while True:
733
+ if cmd:
734
+ _cmd = input(">>> ")
735
+ if not self.proc_cmd(_cmd):
736
+ break
737
+ time.sleep(wait_seconds)
738
+ except KeyboardInterrupt:
739
+ print("\n检测到 Ctrl+C,正在终止所有程序...")
740
+ self.stop_all()
741
+ print("所有程序已终止,退出。")
742
+
743
+
744
+ @deprecated(reason='已改名ProgramWorker')
745
+ class ProcessWorker(ProgramWorker):
746
+ pass
747
+
748
+
749
+ @deprecated(reason='已改名MultiProgramLauncher')
750
+ class MultiProcessLauncher(MultiProgramLauncher):
751
+ pass
752
+
753
+
754
+ class LauncherDashboard:
755
+ def __init__(self, launcher):
756
+ self.launcher = launcher
757
+ self.app = FastAPI()
758
+ self.setup_routes()
759
+
760
+ def setup_routes(self):
761
+ @self.app.get("/", response_class=PlainTextResponse)
762
+ async def home():
763
+ """主页,展示所有任务状态"""
764
+ df = self.launcher.list_workers()
765
+ return self.render_text(df)
766
+
767
+ @self.app.get("/count", response_class=PlainTextResponse)
768
+ async def get_count():
769
+ """返回正在运行的任务数量"""
770
+ count = self.launcher.count_running()
771
+ return f"正在运行的任务数量: {count}\n"
772
+
773
+ @self.app.post("/stop", response_class=PlainTextResponse)
774
+ async def stop_all():
775
+ """停止所有任务"""
776
+ self.launcher.stop_all()
777
+ return "所有任务已停止。\n"
778
+
779
+ @self.app.post("/cleanup", response_class=PlainTextResponse)
780
+ async def cleanup(exit_code: int = None):
781
+ """清理已完成的任务"""
782
+ self.launcher.cleanup_finished(exit_code)
783
+ return "清理完成。\n"
784
+
785
+ def render_text(self, df):
786
+ """生成纯文本格式的任务状态"""
787
+ if df.empty:
788
+ return "没有任务正在运行。\n"
789
+
790
+ # 调整表头以显示新的属性:port 和 locations
791
+ output = ["当前任务状态:\n"]
792
+ output.append(f"{'编号':<5} {'名称':<15} {'PID':<10} {'状态':<10} {'端口':<10} {'位置':<20} {'命令'}")
793
+ output.append("-" * 120)
794
+
795
+ for idx, (_, row) in enumerate(df.iterrows(), start=1):
796
+ name = row['name']
797
+
798
+ # 更鲁棒地处理 pid,考虑 NaN 情况
799
+ pid = "N/A" if pd.isna(row['pid']) else int(row['pid'])
800
+
801
+ # 获取任务状态
802
+ status = "运行中" if row['poll'] is None else "已结束"
803
+
804
+ # 处理 args,确保路径展示更清晰
805
+ args = [a for a in row['args'] if a] if isinstance(row['args'], list) else []
806
+ args_str = "[" + ", ".join(
807
+ repr(arg).replace("\\\\", "\\") if '\\' in arg else repr(arg) for arg in args
808
+ ) + "]"
809
+
810
+ # 处理 port 和 locations
811
+ port = row['port'] if not pd.isna(row['port']) else "N/A"
812
+ locations = str(row['locations'])
813
+
814
+ # 格式化输出
815
+ output.append(f"{idx:<5} {name:<15} {pid:<10} {status:<10} {port:<10} {locations:<20} {args_str}")
816
+
817
+ output.append("\n")
818
+ return "\n".join(output)
819
+
820
+ def run(self, port=8080):
821
+ """启动 FastAPI 服务"""
822
+ import uvicorn
823
+ uvicorn.run(self.app, host="0.0.0.0", port=port, log_level="warning")
824
+
825
+
826
+ def __3_装饰器工具():
827
+ pass
828
+
829
+
830
+ def support_multi_processes_hyx(default_processes=1):
831
+ """ 对函数进行扩展,支持并发多进程运行
832
+ 增加重跑
833
+ 注意被装饰的函数,需要支持 process_count、process_id 两个参数,来获得总进程数,当前进程id的信息
834
+ """
835
+
836
+ def decorator(func):
837
+
838
+ def wrapper(*args, **kwargs):
839
+ process_count = int(kwargs.pop('process_count', default_processes))
840
+ process_id = kwargs.pop('process_id', None)
841
+ shell = kwargs.pop('shell', False)
842
+
843
+ if process_count == 1 or process_id is not None:
844
+ if process_id is None:
845
+ return func(*args, **kwargs)
846
+ else:
847
+ return func(*args, **kwargs, process_count=process_count, process_id=int(process_id))
848
+ else:
849
+ mpl = MultiProcessLauncher()
850
+ for i in range(int(process_count)):
851
+ if isinstance(process_id, int) and i != process_id:
852
+ continue
853
+
854
+ '''
855
+ sys.argv[0] 为 /Users/youx/NutstoreCloudBridge/slns/xlproject/xlproject/m2404ragdata/b清洗/hyx240806统计图表数.py
856
+ 将其转化为 xlproject.m2404ragdata.b清洗.hyx240806统计图表数
857
+ '''
858
+ header = 'xlproject.code4101'
859
+
860
+ root_directory_name = 'xlproject'
861
+ occurrence = 2
862
+ path = sys.argv[0]
863
+
864
+ # 查找第 occurrence 次出现的 root_directory_name 位置
865
+ positions = [i for i in range(len(path)) if path.startswith(root_directory_name, i)]
866
+ if len(positions) < occurrence:
867
+ raise ValueError(
868
+ f"Path does not contain {occurrence} occurrences of the root directory name: {root_directory_name}")
869
+
870
+ # 提取并转换为模块名称
871
+ index = positions[occurrence - 1]
872
+ module_name = os.path.splitext(path[index:])[0].replace(os.path.sep, '.')
873
+
874
+ # todo 这样使用有个坑,process_count、process_id都是以str类型传入的,开发者下游使用容易出问题
875
+ cmds = [
876
+ 'run_python_module',
877
+ '--wait_mode',
878
+ '60',
879
+ module_name,
880
+ func.__name__,
881
+ '--process_count', str(process_count),
882
+ '--process_id', str(i)
883
+ ]
884
+ cmds.extend(map(str, args)) # 添加位置参数
885
+ for k, v in kwargs.items():
886
+ cmds.append(f'--{k}') # 添加关键字参数的键
887
+ cmds.append(str(v)) # 添加关键字参数的值
888
+
889
+ mpl.add_program_python_module(header, cmds, shell=shell)
890
+ mpl.run_endless()
891
+
892
+ return wrapper
893
+
894
+ return decorator
895
+
896
+
897
+ def support_multi_processes(default_processes=1):
898
+ """ 对函数进行扩展,支持并发多进程运行
899
+
900
+ 注意被装饰的函数,需要支持 process_count、process_id 两个参数,来获得总进程数,当前进程id的信息
901
+ """
902
+
903
+ def decorator(func):
904
+
905
+ def wrapper(*args, **kwargs):
906
+ process_count = int(kwargs.pop('process_count', default_processes))
907
+ process_id = kwargs.pop('process_id', None)
908
+ shell = kwargs.pop('shell', False)
909
+
910
+ if process_count == 1 or process_id is not None:
911
+ if process_id is None:
912
+ return func(*args, **kwargs)
913
+ else:
914
+ return func(*args, **kwargs, process_count=process_count, process_id=int(process_id))
915
+ else:
916
+ mpl = MultiProcessLauncher()
917
+ for i in range(int(process_count)):
918
+ if isinstance(process_id, int) and i != process_id:
919
+ continue
920
+
921
+ # todo 这样使用有个坑,process_count、process_id都是以str类型传入的,开发者下游使用容易出问题
922
+ cmds = [func.__name__,
923
+ '--process_count', str(process_count),
924
+ '--process_id', str(i)
925
+ ]
926
+ cmds.extend(map(str, args)) # 添加位置参数
927
+ for k, v in kwargs.items():
928
+ cmds.append(f'--{k}') # 添加关键字参数的键
929
+ cmds.append(str(v)) # 添加关键字参数的值
930
+
931
+ mpl.add_program_python(sys.argv[1], cmds, shell=shell)
932
+ mpl.run_endless()
933
+
934
+ return wrapper
935
+
936
+ return decorator
937
+
938
+
939
+ if __name__ == '__main__':
940
+ pass