5
25
2013
8

给 Python 的正则匹配限制执行时间

看到这个标题,你也许会想,这个需要限制么?不是很快就出来结果了么?

感谢 Just Great Software,虽然我没买它的产品,但是其说明书(可免费下载)中的正则教程详细地论述了这点。所以我在自己的 xmpptalk 机器人中一直不敢接受用户输入的正则表达式。引述其中的一句话:「People with little regex experience have surprising skill at coming up with exponentially complex regular expressions.」(不太懂正则的人经常能令人惊奇地写出指数级复杂度的正则。)

但很不幸,我从这里抄到的匹配网址的正则就有这种问题。在将其的修改版给我的 XMPP 机器人 Lisa 使用后,Lisa 两次被含有括号的链接搞到没响应……

所以,如果要使用用户输入的正则,我必须限制其匹配时间。方法也很简单——使用信号就可以了。当 Python 在匹配正则时如果收到信号,会转而调用信号处理器,然后再接着匹配。如果信号处理器抛出了异常,那么此异常会传播到调用正则匹配的地方,从而中断匹配操作。

示例如下:

#!/usr/bin/env python3

import re
# import regex as re
import signal

def timed_out(b, c):
  print('alarmed')
  raise RuntimeError()

signal.signal(signal.SIGALRM, timed_out)
signal.setitimer(signal.ITIMER_REAL, 0.1, 0)
s = '<aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa>'
r = re.compile(r'''(?:<(?:[^<>]+)*>)+b''')
try:
  r.findall(s)
except RuntimeError:
  print('time exceeded')

被注释掉的那句是调用mrab-regex-hg这个正则引擎的;它不会回溯时出这种问题。

优化下代码,写成方便使用(使用了TimeoutError,所以适用于 Python 3.3+):

import contextlib
import signal

@contextlib.contextmanager
def execution_timeout(timeout):
  def timed_out(signum, sigframe):
    raise TimeoutError

  old_hdl = signal.signal(signal.SIGALRM, timed_out)
  old_itimer = signal.setitimer(signal.ITIMER_REAL, timeout, 0)
  yield
  signal.setitimer(signal.ITIMER_REAL, *old_itimer)
  signal.signal(signal.SIGALRM, old_hdl)
Category: python | Tags: linux python 正则表达式
3
18
2013
16

使用 cx_freeze 打包 Python 程序

首先,当然是给一个目标系统安装 cx_freeze。虽然 cx_freeze 是跨平台的,但没发现它支持在一个平台上打包出另一个平台的二进制文件,而且那样还得准备那个平台上的库文件。我的目标平台是 Windows XP,所以还要准备一个 Dependency Walker

其次,使用cxfreeze-quickstart向导生成配置文件setup.py。当然,如果已经有setup.py文件的话直接修改就是了。下边是一个示例:

import sys

from cx_Freeze import setup, Executable

# Dependencies are automatically detected, but it might need
# fine tuning.
buildOptions = dict(
  packages = [], excludes = [],
  include_files = ['images', 'data.sqlite'],
)

name = 'example'

if sys.platform == 'win32':
  name = name + '.exe'

base = None
if sys.platform == "win32":
    base = "Win32GUI"

executables = [
  Executable('main.py', base = base, targetName = name,
             compress = True,
            )
]

setup(name='Example',
      version = '1.0',
      description = 'An example program',
      options = dict(build_exe = buildOptions),
      executables = executables)

当然,这里有不少我改过的地方。在buildOptions变量中我加了data.sqlite文件和images目录到include_files中去。它们会被放到生成的二进制文件相同的目录。

cx_freeze 在打包 Windows 可执行文件时并不会像 gcc 那样自动添加.exe后缀,所以我要手动加上。

Executable的调用中,要写成base='Win32GUI'这样子。cxfreeze-quickstart目前直接写在第二个参数的位置上的方法是不对的。base的默认值是Console,在 Windows 下运行时是会出现黑色的cmd.exe窗口的。参见StackOverflow: Hide console window with wxPython and cxFreeze

这样还没有完成。打包后测试发现PyQt4.QtNetwork的库文件没有打包进去,可能是因为它是从共享库中引用的,cx_freeze 没有检测到这个依赖。在程序中 import 一下就可以了。另外一个问题是,在没有安装相关库的干净的目标系统上执行时还遇到以下错误信息:

DLL load failed: 找不到指定的模块
DLL load failed: The specified module could not be found.

其上还有一个 Traceback。这是因为有些(据说主要是 Microsoft Visual C++ Redistributable 的) DLL (非 Python 模块)没有被打包进去。从 Traceback 中找到引发这个错误的 DLL(或者 pyd)文件名,将其在打包系统中使用前边提到的 Dependency Walker 打开,在左边的树形库列表中找到目标系统上可能没有的库文件,将其复制到 cx_freeze 生成二进制文件的目录中即可。比如我这里需要手动添加msvcr100.dllmsvcp100.dll

最后,打包过的程序执行时__main__模块是没有__file__属性的,所以无法通过这个变量来切换到程序所在的目录,进而读取自己的数据文件。但是,打包过的程序有sys.frozen属性,程序自身的路径存放在sys.executable中,所以程序中需要作下判断:

import os
import sys

if hasattr(sys, 'frozen'):
  me = sys.executable
else:
  me = __file__
mydir = os.path.dirname(me)

参见StackOverflow: How do I get the path of the current executed file in python?

最终打出来的可执行文件和库文件比较大,PyQt 程序总共有 40M 之多。使用 7z 压缩之后能减小到 10M 多。

Category: python | Tags: python windows
12
1
2012
6

继续修改邮件主题

上回说到,中文邮件列表主题标签中的序号让我这位 mutt 用户很是眼烦,于是拿 Python 写了个脚本处理掉了序号。然而,主题中让人眼烦的岂止是标题?看看下面这些:

回复: Re: [shlug] 求助:c程序的效率比java还慢
[CPyUG] Re: [CPyUG:183226] [OT] 自动 tag 加上序号了?

既然已经知道如何处理了,加上有风间星魂的基于正则的极简语法解析器,不妨再处理下。达到的效果如下:

  • 去掉所有的回复字样,在最开头加上「Re: 」
  • 去掉所有标签内的数字
  • 去掉重复的标签

代码在这里。修改后的解析器在这里

Category: python | Tags: mail mutt python
11
24
2012
0

使用 procmail 修改邮件主题

Google Groups 上的邮件列表可以根据管理员的设置自动添加在邮件主题前添加指定的字符串。对于 mutt,固定的字符串没什么,但当这个添加的字符串不断地变化呢?比如这个字符串设置成[vim-cn:%d]%d会被邮件的序号所取代,于是每封邮件都有了不同主题。(对于回复邮件,按 RFC 5322 3.6.5 节的意思,应当将开头的一个Re:不予考虑。)

这样的邮件会话,mutt 一看,哎呀,会话中出现了新的主题,得显示一下啦。于是,本来通常情况下一个会话只会显示一次的邮件主题,在添加了邮件序号后变成了N个。对比下图中黄线上方和下方的区别。

mutt subject display

本来呢,vim-cn 列表是下边那个样子的。最近觉得 vim-cn 的邮件多了起来,看起来眼烦,正好有管理权限,就去把%d删掉了。可谁知我才收到几封不带序号的 vim-cn 邮件呢,python-cn 却开始加序号了,不知道管理员是不是想看看第20万封邮件是谁发的。唉,既然改变不了那个列表的设置,那么就改变本地的邮件处理好了。

在开始使用 mutt 等工具的时候,我一直是把 procmail 当成邮件分发和过滤工具的。如今需要它来修改邮件了,语法还不会呢。另外它的示例中大多是 formail 工具,但我实在不想再学一种语法晦涩的工具了,于是自己搞。本来觉得挺简单的,一句 sed 就能搞定的东西,等真正查看邮件源码时才发现,远没有想像的那么简单!

Subject: =?GB2312?B?W0NQeVVHOjE4MzI0OV0gcHl0aG9uIL+qt6LN+NKz087Pt7rNyta7+rbL?=
        =?GB2312?B?zfjC59POz7e+rdHpx/PW+g==?=

第一,它编码过了;第二,它分成了多行。哦还有第三,邮件正文即使出现也不要处理,不然我这文章发过去不是变了样么?

后两点还好,awk 可以搞定,可是这编码不是那么容易呢,于是用上了 Python。既然 Python 3.3 已经发布,所以试了试新的yield from语法。反正我不认为我会需要在只有 Python 3.2 或者更早的系统上使用这个脚本。

#!/usr/bin/env python3
# vim:fileencoding=utf-8

import sys
import re
from email import header

subject_seq = re.compile(r'''((?:..[::]\s?)?  # Re、回复等
                             \[[^:]+)
                             :\d+              # 要删除的序号''', re.X)

def stripSeq(input):
  subject = None
  while True:
    l = next(input)
    if l.startswith('Subject: '):
      # Subject appears
      subject = l
      continue
    elif subject and l[0] in ' \t':
      # Subject continues
      subject += l
    elif subject:
      # Subject ends
      s = subject[9:]
      h = header.decode_header(s)
      assert len(h) == 1, 'unexpected subject line: ' + s
      s, enc = h[0]
      if isinstance(s, bytes):
        s = s.decode(enc)
      m = subject_seq.match(s)
      if not m:
        yield subject
      else:
        s = m.group(1) + s[m.end():]
        yield 'Subject: ' + header.Header(s, 'utf-8').encode() + '\n'
      subject = None
      yield l
    elif l.strip() == '':
      # mail body
      yield from input
    else:
      yield l

if __name__ == '__main__':
  sys.stdout.writelines(stripSeq(iter(sys.stdin)))

Github 上的地址

procmail 的规则如下,参考了 Stackoverflow 的这个回答

:0 fw
| ~/scripts/python/pyexe/procmail.py
Category: python | Tags: mail mutt python procmail
11
10
2012
8

如何更安全地覆写数据文件

经常地,程序在开始执行某项任务需要从文件读取数据。在任务完成后数据得到更新,新的数据会覆写到之前读取的文件中。怎么将数据写回到文件呢?一个直觉的方案是:

with open(datafile, 'w') as f:
  f.write(data)

在通常情况下,它能够正确地完成写回数据的任务。如果出于某种原因文件打开失败,通常也不会有人忘记处理。但是,当写入操作失败了呢?

时不时地编译程序看到 gcc 大把地警告:

警告:忽略声明有 warn_unused_result 属性的‘write’的返回值 [-Wunused-result]

在 Python 中,写文件时如果失败会抛出异常,上层的异常处理机制似乎能够作出相应的应对。但是,真的尽力了吗?

我也曾以为这样不会出问题。直到有一天,本地信箱里出现了这样的错误信息:

OSError: [Errno 28] No space left on device

可能是由于内核的某个 bug,我本来就所剩无几的 /home 分区没空闲空间了。一个 cronjob 在写回数据时发生异常。于是,新的数据没能写入文件。那旧数据呢?因为是以「写」方式打开文件,所以它也没了……

在那次事件之后,那段写回数据的代码变成了这个样子:

with open(datafile + '.tmp', 'w') as f:
  f.write(t)
# if the above write failed (because disk is full), the old data should be kept
os.rename(datafile + '.tmp', datafile)

注意:测试表明不使用with或者显式地关闭文件的做法是有问题的,即使在 CPython 中。

try:
  open('/dev/full', 'w').write('abc')
except:
  print('fine.')

在 Python 2.7 中会打印错误信息,Python 3.3.0 中无任何信息。都没有预料中的异常被捕获。

>>> python t.py
>>> python2 t.py
close failed in file object destructor:
IOError: [Errno 28] No space left on device

今天之所以写这个,是因为 Arch Linux CN 的群服务器遇到磁盘配额用尽的问题。XMPP 服务器 Prosody 在写入联系人信息时只写了一小部分,大部分数据丢失。这里有 bug 报告

2013年7月21日更新:Sublime Text 2 作为商业软件,竟然不仅不采用「新建+重命名」的方式写入文件,而且连写入是否成功都不检查。难怪 Linux 版中文输入法的问题迟迟不修复,原来连造成用户数据丢失的问题都无所谓

Category: 编程 | Tags: python prosody
10
26
2012
4

在 Python 中流式解压 gzip 数据

在处理 HTTP 响应时,我需要来一段数据就处理一段。为了节约网络资源,我开启了 gzip 传输。然后问题来了:有什么办法把 gzip 过的数据一段段传进去,它能一段段地解压并吐出数据呢?gzip 模块虽然支持fileobj参数,但是它读不到数据时认为数据流已经结束,然后进行 CRC 校验……这里有个人也这样尝试过。解决办法也有了

d = zlib.decompressobj(16+zlib.MAX_WBITS)

使用一个神奇的数字构造一个decompress对象,然后不断地调用它的decompress方法就可以得到一段段的解压数据了 :-)

然后,我发现我真的想太多了——我用的可流式解析 HTTP 响应的库 http-parser 原生支持解压的,而且同时支持 gzip 和 deflate 方法!这个库能很好地适配到异步 I/O 框架中,可是文档太少了,这个解压的支持 docstring 里都没写,看了源码才知道 :-(

Category: python | Tags: Python HTTP
10
7
2012
0

拼音声调数字转字符

Suǒwèide 「pīnyīn shēngdiào shùzì zhuǎn zìfú」 jiùshì bǎ 「pīnyīn+shùzìdiàbiǎo de shēngdiào」 zhuǎnchén Unicode zìfú biǎoshì.

所谓的「拼音声调数字转字符」就是把「拼音+数字表示的声调」转成 Unicode 字符表示,为了是做成 fcitx 的拼音输入插件,以方便输入上段的内容。

算法是参考别人的,把所有带声调的音节后缀穷举出来再转换,简单暴力好用。我改写的 Python 3 版在 github/winterpy 上,支持大写和「ü」。

为了在 fcitx 中使用,我又改写了一 Lua 版本,代码如下:

#!/usr/bin/env lua

-- http://www.robertyu.com/wikiperdido/Pinyin%20Parser%20for%20MoinMoin

-- definitions
-- For the pinyin tone rules (which vowel?), see
-- http://www.pinyin.info/rules/where.html

local strsub = string.gsub
local _strupper = string.upper

-- map (final) constanant+tone to tone+constanant
mapConstTone2ToneConst = {
  n1 = '1n',
  n2 = '2n',
  n3 = '3n',
  n4 = '4n',
  ng1 = '1ng',
  ng2 = '2ng',
  ng3 = '3ng',
  ng4 = '4ng',
  r1 = '1r',
  r2 = '2r',
  r3 = '3r',
  r4 = '4r',
}

-- map vowel+vowel+tone to vowel+tone+vowel
mapVowelVowelTone2VowelToneVowel = {
  ai1 = 'a1i',
  ai2 = 'a2i',
  ai3 = 'a3i',
  ai4 = 'a4i',
  ao1 = 'a1o',
  ao2 = 'a2o',
  ao3 = 'a3o',
  ao4 = 'a4o',
  ei1 = 'e1i',
  ei2 = 'e2i',
  ei3 = 'e3i',
  ei4 = 'e4i',
  ou1 = 'o1u',
  ou2 = 'o2u',
  ou3 = 'o3u',
  ou4 = 'o4u',
}

-- map vowel-number combination to unicode
mapVowelTone2Unicode = {
  a1 = 'ā',
  a2 = 'á',
  a3 = 'ǎ',
  a4 = 'à',
  e1 = 'ē',
  e2 = 'é',
  e3 = 'ě',
  e4 = 'è',
  i1 = 'ī',
  i2 = 'í',
  i3 = 'ǐ',
  i4 = 'ì',
  o1 = 'ō',
  o2 = 'ó',
  o3 = 'ǒ',
  o4 = 'ò',
  u1 = 'ū',
  u2 = 'ú',
  u3 = 'ǔ',
  u4 = 'ù',
  v1 = 'ǜ',
  v2 = 'ǘ',
  v3 = 'ǚ',
  v4 = 'ǜ',
}

function strupper(c)
  local specials = {
    ['ā'] = 'Ā',
    ['á'] = 'Á',
    ['ǎ'] = 'Ǎ',
    ['à'] = 'À',
    ['ē'] = 'Ē',
    ['é'] = 'É',
    ['ě'] = 'Ě',
    ['è'] = 'È',
    ['ī'] = 'Ī',
    ['í'] = 'Í',
    ['ǐ'] = 'Ǐ',
    ['ì'] = 'Ì',
    ['ō'] = 'Ō',
    ['ó'] = 'Ó',
    ['ǒ'] = 'Ǒ',
    ['ò'] = 'Ò',
    ['ū'] = 'Ū',
    ['ú'] = 'Ú',
    ['ǔ'] = 'Ǔ',
    ['ù'] = 'Ù',
    ['ǜ'] = 'Ǜ',
    ['ǘ'] = 'Ǘ',
    ['ǚ'] = 'Ǚ',
    ['ǜ'] = 'Ǜ',
  }
  if specials[c] then
    return specials[c]
  else
    return _strupper(c)
  end
end

function ConvertPinyinToneNumbers(lineIn)
  local lineOut = lineIn

  -- first transform
  for x, y in pairs(mapConstTone2ToneConst) do
    lineOut = strsub(strsub(lineOut, x, y), strupper(x), strupper(y))
  end

  -- second transform
  for x, y in pairs(mapVowelVowelTone2VowelToneVowel) do
    lineOut = strsub(strsub(lineOut, x, y), strupper(x), strupper(y))
  end

  -- third transform
  for x, y in pairs(mapVowelTone2Unicode) do
    lineOut = strsub(strsub(lineOut, x, y), strupper(x), strupper(y))
  end

  return strsub(strsub(lineOut, 'v', 'ü'), 'V', 'Ü')
end

local function main()
  local lineOut
  for lineIn in io.stdin:lines() do
    lineOut = ConvertPinyinToneNumbers(lineIn)
    print(lineOut)
  end
end

main()

很可惜的是,fcitx 的 Lua 模块目前不支持屏蔽数字键选字,所以暂无法在 fcitx 中使用。

Category: 编程 | Tags: Lua Python 中文支持 拼音
9
30
2012
3

使用 PyQt 滚动播放卫星云图

自从和 GNOME 开发者接触过之后,我决定放弃断断续续学了一段时间的 GTK 而转向 Qt 了。看了两三天的 PyQt4 tutorial,恰好遇到一需要界面的脚本,本来我会搞成 Web 的,但既然学了 Qt 嘛,当然得练练。

起因是这样子的。一天和群里的人聊到天气之类的,然后有人扔了张卫星云图出来。多年未见的云图啊,再次见到感觉好亲切,虽然我的地理和气象知识已经忘记了好多了。然后得到了这个网页。本来呢,他们想搞成在以前的天气预报里那样逐幅图像显示的滚动播放效果的,但政府网站嘛,出点问题很正常,比如我第一次播放的时候图片基本上动不了(可能是因为没有预加载以及网络不畅造成的),第二次的时候图像时不时闪一下。而且这样可控性太差,想看的时段根本看不清就跳走了,不想看的时段却一直慢慢地跳。所以我拿 Python 写了个通过 slider 滑块来控制显示的图像简易脚本。写完后才发现使用 sxiv 然后按住N/P键是一样的效果……

代码如下,需要 PyQt4 或者 PySide。对于后者,Arch 用户可以从 Arch Linux CN 源安装而不必通过 AUR 自行编译(C++ 编译很费时的)。

#!/usr/bin/env python3
# vim:fileencoding=utf-8

# TODO: 并发下载
# TODO: 下载进度显示
# TODO: 允许加载已经下载但网页上没有的云图
# TODO: 网络作为可选

import os
import sys
import re
import gzip
import urllib.request

pic_dir = '.'

try:
  from PySide import QtGui, QtCore
except ImportError:
  from PyQt4 import QtGui, QtCore

def getPage():
  request = urllib.request.Request('http://www.weather.com.cn/static/product_video_v1.php?class=JC_YT_DL_WXZXCSYT')
  request.add_header('Accept-Encoding', 'gzip')
  res = urllib.request.urlopen(request)
  return gzip.decompress(res.read()).decode('utf-8')

def getPics(page):
  urlre = re.compile(r'\bhttp://i.weather.com.cn/i/product/pic/m/sevp_nsmc_wxcl_asc_e99_achn_lno_py_\d{17}.jpg\b')
  return sorted({x.replace('/m/', '/l/') for x in urlre.findall(page)})

def download(pics):
  ret = []
  for p in pics:
    file = os.path.split(p)[1]
    file = os.path.join(pic_dir, file)
    ret.append(file)
    if os.path.exists(file):
      continue
    data = urllib.request.urlopen(p).read()
    open(file, 'wb').write(data)
  return ret

class YuntuShow(QtGui.QWidget):
  def __init__(self, pics):
    super().__init__()
    self.pics = pics
    self.initUI()

  def initUI(self):
    pic = QtGui.QPixmap(self.pics[-1])
    self.pic = piclabel = QtGui.QLabel(self)
    piclabel.setPixmap(pic)

    slider = QtGui.QSlider(QtCore.Qt.Horizontal, self)
    slider.setTickPosition(QtGui.QSlider.TicksBelow)
    m = len(self.pics) - 1
    slider.setMaximum(m)
    slider.setSliderPosition(m)
    slider.valueChanged[int].connect(self.changePic)

    vbox = QtGui.QVBoxLayout()
    vbox.addWidget(piclabel)
    vbox.addWidget(slider)
    self.setLayout(vbox)

    self.resize(960 + 10, 720 + 50)
    self.setWindowTitle('YuntuShow')
    self.show()

  def keyPressEvent(self, e):
    if e.key() == QtCore.Qt.Key_Q:
      self.close()

  def changePic(self, value):
    pic = QtGui.QPixmap(self.pics[value])
    self.pic.setPixmap(pic)

def main():
  urls = getPics(getPage())
  pics = download(urls)
  app = QtGui.QApplication(sys.argv)
  yt = YuntuShow(pics)
  sys.exit(app.exec_())

if __name__ == '__main__':
  main()

短吧?而且我也只看了三天的教程用的时候查了下文档而已哦~比起 GTK 来快速多了。不过这其中有个很重要的原因是我已经通过 GTK 了解了基本的 GUI 编程了。

因为最开始会从网络下载数据和图像,所以要等一会儿窗口才会出现。

Category: python | Tags: python Qt pyqt pyside
8
28
2012
10

UDP打洞实验

两台没有外网 IP、在 NAT 后边的主机如何直连?UDP打洞通常可行,但是需要第三方服务器。方法如下:

在服务器 S 上监听一个 UDP 端口,在收到 UDP 数据包后把源地址发回去。代码如下(github):

import sys
import time
import socket

def main(port):
  s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  s.bind(('', port))
  try:
    while True:
      data, addr = s.recvfrom(4096)
      back = 'Your address is %r\n' % (addr,)
      s.sendto(back.encode(), addr)
      print(time.strftime('%Y-%m-%d %H:%M:%S'), addr, 'just sent us a message:', data.decode('utf-8', 'replace'), end='')
  except KeyboardInterrupt:
    print()

if __name__ == '__main__':
  try:
    main(int(sys.argv[1]))
  except (ValueError, IndexError):
    sys.exit('which port to listen?')

主机 A 发送数据包:

$ socat readline udp:xmpp.vim-cn.com:2727,sourceport=4567
my addr?
Your address is ('a.b.c.d', 40060)

输入任意消息并回车,一个 UDP 就从本地的 4567 发送出去了。从上述示例我们可以看到,NAT 设备转发时是从 40060 端口发送出去的。为了让服务器返回的数据能够到达内网主机,在一段时间内,NAT 设备会记住外网来自 40060 端口的 UDP 数据包要发送给主机 a.b.c.d 的 4567 端口。完全圆锥型NAT不会在意外部数据包是从什么地方发回来的。受限圆锥型NAT会忽略掉其它主机的数据包,上例中只认可来自 xmpp.vim-cn.com 的数据包。端口受限圆锥型NAT更进一步地要求源端口(上例中是 2727)必须跟之前发出的数据包的目的端口一致。当然,「之前发出的数据包」不必是最后一个。所以,除了最后一种——对称NAT——之外,其它类型的NAT都是有可能成功穿透的。参见维基百科条目网络地址转换STUN

后来通过 pystun 程序,我得知我所处的 NAT 是完全圆锥型的。

在知道 A 的发送地址后,主机 B 就可以向这个地址发送数据了。接下来的操作使用 socat 命令就是:

# host A
$ socat readline udp-listen:4567
# host B
$ socat readline udp:A:4567

然后 B 先发送数据让 A 知道 B 的地址(socat 会 connect 到这个地址),双方就可以相互通信了。当然,因为是 UDP 协议,所以通信是不可靠的,丢包啊乱序啊都有可能。

2013年10月13日更新:想要连接到 NAT 后边的 mosh 请看这里

Category: 网络 | Tags: python 网络 socat UDP

部分静态文件存储由又拍云存储提供。 | Theme: Aeros 2.0 by TheBuckmaker.com