`
chinamming
  • 浏览: 141502 次
  • 性别: Icon_minigender_1
  • 来自: 北京
文章分类
社区版块
存档分类
最新评论

Python源码分析5 – 语法分析器PyParser

阅读更多

Introduction

上一篇文章我们分析了Python是如何对语法文件Grammar进行预处理,生成语法数据,并在运行时生成Acclerators加速语法分析的过程。当分析完这些内容之后,下一步便是分析Python中语法分析的机制。回顾一下Python的整个处理流程:

1. PyTokenizer进行词法分析,把源程序分解为Token

2. PyParser根据Token创建CST

3. CST被转换为AST

4. AST被编译为字节码

5. 执行字节码

语法分析处于整个流程的第二步,其目标是处理token生成CST(Concrete Syntax Tree)。因此,在具体分析PyParser的工作方式之前,我们先看一下什么是CST。

CST (Concrete Syntax Tree)

CST (Concrete Syntax Tree) 和AST (Abstract Syntax Tree) 类似,都是语法分析所获得的中间结果。他们的不同之处在于,CST直接对应语法分析的匹配的过程,是直接生成的,含有大量冗余信息。而AST省略了中间的冗余信息,直接对应实际的语义,也就是分析的结果。用例子说明要清楚一些:

假设有这样一个表达式a,

CST是这样的:(->表示从父结点到子结点)

file_input -> stmt -> simple_stmt -> small_stmt -> expr_stmt -> testlist -> test ->or_test ->and_test ->not_test -> comparison -> expr -> xor_expr -> and_expr -> shift_expr -> arith_expr -> term -> factor -> power -> atom -> (NAME, “a”)

而AST则是:

(stmt_ty, expr_kind) -> (expr_ty, name_kind) ->(“a”)

可以看到CST表述了整个分析a的过程,从file_input一直推导到最后的NAME,每一步推导都成了树的结点,而大部分信息都可以说是无用的。AST的结构要简单和直接的多,直接表明a是一个表达式语句(假定a是一个单独的语句),内容是一个标示符,值为”a”。Python的语法分析生成的是CST而非AST,之后Python会调用PyAst_FromNode将CST转换为AST。

CST的结点称为Node,其结构定义在node.h中:

typedef struct _node {

short n_type;

char *n_str;

int n_lineno;

int n_col_offset;

int n_nchildren;

struct _node *n_child;

} node;

Field

Description

n_type

结点类型,终结符定义在token.h中,而非终结符定义在graminit.h中

n_str

结点所对应的字符串的内容

n_lineno

对应的行号

n_col_offset

列号

n_nchildren

子结点的个数

n_child

子结点数组,动态分配内存

Python提供了下面的函数/宏来操作CST,同样定义在node.h中:

PyAPI_FUNC(node *) PyNode_New(int type);

PyAPI_FUNC(int) PyNode_AddChild(node *n, int type,

char *str, int lineno, int col_offset);

PyAPI_FUNC(void) PyNode_Free(node *n);

/* Node access functions */

#define NCH(n) ((n)->n_nchildren)

#define CHILD(n, i) (&(n)->n_child[i])

#define RCHILD(n, i) (CHILD(n, NCH(n) + i))

#define TYPE(n) ((n)->n_type)

#define STR(n) ((n)->n_str)

/* Assert that the type of a node is what we expect */

#define REQ(n, type) assert(TYPE(n) == (type))

PyAPI_FUNC(void) PyNode_ListTree(node *);

PyNode_New和PyNode_Delete负责创建和释放node结构:

node *

PyNode_New(int type)

{

node *n = (node *) PyObject_MALLOC(1 * sizeof(node));

if (n == NULL)

return NULL;

n->n_type = type;

n->n_str = NULL;

n->n_lineno = 0;

n->n_nchildren = 0;

n->n_child = NULL;

return n;

}

void

PyNode_Free(node *n)

{

if (n != NULL) {

freechildren(n);

PyObject_FREE(n);

}

}

static void

freechildren(node *n)

{

int i;

for (i = NCH(n); --i >= 0; )

freechildren(CHILD(n, i));

if (n->n_child != NULL)

PyObject_FREE(n->n_child);

if (STR(n) != NULL)

PyObject_FREE(STR(n));

}

NCH/CHILD/RCHILD/TYPE/STR是用来封装对node的成员的访问的。需要提一下的是,CHILD(n, i)是从左边开始算,传入i的是正数,而RCHILD(n, i)则是从右边往左,传入的参数i是负数。

PyNode_AddChild将一个新的子结点加入到子结点数组中。由于结点数量是动态变化的,因此在当前分配的结点数组大小不够的时候,Python会调用realloc重新分配内存。内存分配是一个非常耗时的动作,因此Python在PyNode_AddChild之中用到了和std::vector类似的技巧来尽量减少内存分配的次数,每次增长的时候都会根据某个规则进行RoundUp,而不是需要多少就分配多少。XXXROUNDUP函数负责进行此运算。n<=1时, 返回n。1<n<=128的时候,会RoundUp到4的倍数。n>128, 会调用fancy_roundup来RoundUp到2的幂。

#define XXXROUNDUP(n) ((n) <= 1 ? (n) : /

(n) <= 128 ? (((n) + 3) & ~3) : /

fancy_roundup(n))

有了XXXROUNDUP之后,实现PyNode_AddChild就非常直接了:

int

PyNode_AddChild(register node *n1, int type, char *str, int lineno, int col_offset)

{

const int nch = n1->n_nchildren;

int current_capacity;

int required_capacity;

node *n;

if (nch == INT_MAX || nch < 0)

return E_OVERFLOW;

current_capacity = XXXROUNDUP(nch);

required_capacity = XXXROUNDUP(nch + 1);

if (current_capacity < 0 || required_capacity < 0)

return E_OVERFLOW;

if (current_capacity < required_capacity) {

n = n1->n_child;

n = (node *) PyObject_REALLOC(n,

required_capacity * sizeof(node));

if (n == NULL)

return E_NOMEM;

n1->n_child = n;

}

n = &n1->n_child[n1->n_nchildren++];

n->n_type = type;

n->n_str = str;

n->n_lineno = lineno;

n->n_col_offset = col_offset;

n->n_nchildren = 0;

n->n_child = NULL;

return 0;

}

值得注意的是该函数并没有记录下当前的最大容量,这个可以通过XXXROUNDUP(nch)计算出来。

PyParser

PyParser所作的事情就是根据token生成CST。整个生成树的过程其实也就是一个遍历语法图的过程。在前一篇文章中我们知道语法图是由多个DFA组成的,而输入的token和当前的所处的状态结点可以决定下一个状态结点。由于PyParser是在多个DFA中遍历,因此当结束了某个DFA的遍历需要回到上一个DFA,这些信息都是由一个专门的栈保存着。PyParser所对应的结构是parser_state,这个结构保存着PyParser的内部状态,如下:

typedef struct {

stack p_stack; /* Stack of parser states */

grammar *p_grammar; /* Grammar to use */

node *p_tree; /* Top of parse tree */

#ifdef PY_PARSER_REQUIRES_FUTURE_KEYWORD

unsigned long p_flags; /* see co_flags in Include/code.h */

#endif

} parser_state;

Field

Description

p_stack

PyParser状态栈,每一个状态都对应着某个DFA中的某个状态

p_grammar

语法图的指针

p_tree

CST的根结点

p_flags

PyParser的flags,唯一用到的是CO_FUTURE_WITH_STATEMENT

状态栈的定义如下:

typedef struct {

stackentry *s_top; /* Top entry */

stackentry s_base[MAXSTACK]; /* Array of stack entries */

/* NB The stack grows down */

} stack;

状态栈中的状态如下:

typedef struct {

int s_state; /* State in current DFA */

dfa *s_dfa; /* Current DFA */

struct _node *s_parent; /* Where to add next node */

} stackentry;

Field

Description

s_state

当前DFA中的状态ID

s_dfa

当前DFA指针

s_parent

当前的parent结点。PyParser会把新的结点作为Child加到栈顶状态的s_parent结点中去

PyParser调用下面这些函数来操作栈:

static void s_reset(stack *s);

#define s_empty(s) ((s)->s_top == &(s)->s_base[MAXSTACK])

static int s_push(register stack *s, dfa *d, node *parent)

#define s_pop(s) (s)->s_top++

这些函数的实现非常简单,不再赘述。

PyParser所支持的“成员”函数如下:

parser_state *PyParser_New(grammar *g, int start);

void PyParser_Delete(parser_state *ps);

int PyParser_AddToken(parser_state *ps, int type, char *str,

int lineno, int col_offset,int *expected_ret);

void PyGrammar_AddAccelerators(grammar *g);

PyParser_New & PyParser_Delete显然是用于创建和销毁PyParser的实例的,和PyTokenizer一致。PyGrammar_AddAccelerators在我的前一篇Python源码研究4中提到过,主要用于处理Python的Grammar数据生成Accelerator加快语法分析的速度。在这些函数中,最为核心的是PyParser_AddToken,这个函数的作用是根据PyTokenizer所获得的token和当前所处的状态/DFA,跳转到下一个状态,并添加到CST中。在parsetok函数中,有如下的代码(省略了大部分):

parser_state *ps;

ps = PyParser_New(g, start);

for (;;) {

char *a, *b;

int type;

type = PyTokenizer_Get(tok, &a, &b);

PyParser_AddToken(ps, (int)type, str, tok->lineno, col_offset, &(err_ret->expected));

}

Parsetok的作用是分析某段代码。可以看到,parsetok会反复调用PyTokenizer_Get获得下一个token,然后将反复将获得的token传给PyParser_AddToken来逐步构造整个CST,当所有token都处理过了之后,整棵树也就建立完毕了。

PyParser API

Python不会直接调用PyParser和PyTokenizer的函数,而是直接调用下面的这些Python API:

PyAPI_FUNC(node *) PyParser_ParseString(const char *, grammar *, int,

perrdetail *);

PyAPI_FUNC(node *) PyParser_ParseFile (FILE *, const char *, grammar *, int,

char *, char *, perrdetail *);

PyAPI_FUNC(node *) PyParser_ParseStringFlags(const char *, grammar *, int,

perrdetail *, int);

PyAPI_FUNC(node *) PyParser_ParseFileFlags(FILE *, const char *, grammar *,

int, char *, char *,

perrdetail *, int);

PyAPI_FUNC(node *) PyParser_ParseStringFlagsFilename(const char *,

const char *,

grammar *, int,

perrdetail *, int);

/* Note that he following function is defined in pythonrun.c not parsetok.c. */

PyAPI_FUNC(void) PyParser_SetError(perrdetail *);

PyAPI_FUNC宏是用于定义公用的Python API,表明这些函数可以被外界调用。在Windows上面Python Core被编译成一个DLL,因此PyAPI_FUNC等价于大家常用的__declspec(dllexport)/__declspec(dllimport)。这些函数把PyParser和PyTokenizer对象的接口和细节包装起来,使用者可以直接调用PyParser_ParseXXXX函数来使用PyParser和PyTokenizer的功能而无需知道PyPaser/PyTokenizer的工作方式,这可以看作是一个典型的Façade模式。以PyParser_ParseFile为例,该函数分析传入的FILE返回生成的CST。其他的函数与此类似,只是分析的对象不同和传入参数的不同。

PyParser_AddToken implementation

在全面了解了PyParser的接口和调用方式之后,我们来看一下PyParser_AddToken的具体实现。PyParser_AddToken会调用3个内部函数来做处理:classify, push, shift

Classify根据type和str,确定对应的Label,实现如下:

static int

classify(parser_state *ps, int type, char *str)

{

grammar *g = ps->p_grammar;

register int n = g->g_ll.ll_nlabels;

if (type == NAME) {

register char *s = str;

register label *l = g->g_ll.ll_label;

register int i;

for (i = n; i > 0; i--, l++) {

if (l->lb_type != NAME || l->lb_str == NULL ||

l->lb_str[0] != s[0] ||

strcmp(l->lb_str, s) != 0)

continue;

#ifdef PY_PARSER_REQUIRES_FUTURE_KEYWORD

if (!(ps->p_flags & CO_FUTURE_WITH_STATEMENT)) {

if (s[0] == 'w' && strcmp(s, "with") == 0)

break; /* not a keyword yet */

else if (s[0] == 'a' && strcmp(s, "as") == 0)

break; /* not a keyword yet */

}

#endif

D(printf("It's a keyword/n"));

return n - i;

}

}

{

register label *l = g->g_ll.ll_label;

register int i;

for (i = n; i > 0; i--, l++) {

if (l->lb_type == type && l->lb_str == NULL) {

D(printf("It's a token we know/n"));

return n - i;

}

}

}

D(printf("Illegal token/n"));

return -1;

}

可以看到classify作了一些针对NAME的特殊处理。有两种NAME,一种是标示符,一种是关键字。语法图中标示符的str是NULL(因为在语法图中无法实现知道标示符的内容,也不用知道),而关键字的str是有值的,不同的关键字对应着不同的语句,因此需要进行比较。classify首先处理关键字的情况,如果不是关键字则当普通情况下处理。两种情况下都需要遍历整个LabelList来确定Label的ID,唯一不同的是关键字的情况下需要比较具体的str内容而其他情况下无须比较。这里的效率看起来还是有改进的余地,在非关键字情况下可以用一个数组来做索引,直接根据type查找到id。在关键字的情况下至少可以把关键字作为一个单独的Token/非终结符来识别,这样就不用和普通标示符的情况弄混了,而目前的实现即使是普通标示符也会进到第一个if语句中进行关键字的判断。还可以使用hash来快速查找等。

Shift改变当前栈顶的状态(注意并不跳转到另外的DFA,这个改变只限于单个DFA中,跳转到另外一个DFA需要调用push压栈),并把当前的type/str作为一个新的子结点加入到栈顶的s_parent结点,通常s_parent结点对应着当前的DFA的结点。

假设我们在DFA0中,当前栈顶为(DFA0, s1),type=NAME

NAME

DFA0: s0 -> s1 -> s2

从s1跳转到s2不会离开DFA0,不用进栈,只需改变当前(DFA0, s1)到(DFA0, s2)即可。

static int

shift(register stack *s, int type, char *str, int newstate, int lineno, int col_offset)

{

int err;

assert(!s_empty(s));

err = PyNode_AddChild(s->s_top->s_parent, type, str, lineno, col_offset);

if (err)

return err;

s->s_top->s_state = newstate;

return 0;

}

Push同样也会把type/str作为新的子结点n加入到当前s_parent结点并改变当前栈顶的状态为newstate。但是newstate并非是下一个状态,而是当新的DFA遍历完毕之后退栈才会到。然后,把目标DFA压栈。新生成的子结点n作为新的s_parent。

还是举一个例子比较容易说明这个过程:

‘a’

DFA 1: s0 -> s1

DFA1

DFA 0: s1 -> s2

假设当前我们处于(DFA0, s1), type/str告诉我们下一个状态为s2,label是非终结符DFA1,对应着DFA1,因此我们会把当前栈顶(DFA0, s1)修改为(DFA0, s2),然后跳转到DFA1中进行匹配,同时(DFA1, s0)作为新的栈顶压栈,当(DFA1,s0)退栈之后,说明DFA1匹配完毕,回到(DFA0, s2)。

具体的实现如下:

static int

push(register stack *s, int type, dfa *d, int newstate, int lineno, int col_offset)

{

int err;

register node *n;

n = s->s_top->s_parent;

assert(!s_empty(s));

err = PyNode_AddChild(n, type, (char *)NULL, lineno, col_offset);

if (err)

return err;

s->s_top->s_state = newstate;

return s_push(s, d, CHILD(n, NCH(n)-1));

}

在讨论完这些函数之后就可以开始着手研究PyParser_AddToken的实现了:

int

PyParser_AddToken(register parser_state *ps, register int type, char *str,

int lineno, int col_offset, int *expected_ret)

{

register int ilabel;

int err;

D(printf("Token %s/'%s' ... ", _PyParser_TokenNames[type], str));

/* Find out which label this token is */

ilabel = classify(ps, type, str);

if (ilabel < 0)

return E_SYNTAX;

PyParser_AddToken第一步是根据type和str,调用classify获得对应的label。

PyParser_AddToken获得了对应的label之后,进入一个for loop:

/* Loop until the token is shifted or an error occurred */

for (;;) {

/* Fetch the current dfa and state */

register dfa *d = ps->p_stack.s_top->s_dfa;

register state *s = &d->d_state[ps->p_stack.s_top->s_state];

D(printf(" DFA '%s', state %d:",

d->d_name, ps->p_stack.s_top->s_state));

这个for loop反复拿到当前栈顶,也就是DFA和DFA状态,然后根据当前的状态和Label决定下一步的动作。基本的规则如下:

/* Check accelerator */

if (s->s_lower <= ilabel && ilabel < s->s_upper) {

register int x = s->s_accel[ilabel - s->s_lower];

有对应的Accelerator,为x

1. X第8位为1,说明x对应着一个非终结符,记录着目标状态的DFA ID和状态

DFA ID

1

目标状态,7bit

在这种情况下会跳转到另外一个DFA,把目标DFA+状态压栈。

if (x != -1) {

if (x & (1<<7)) {

/* Push non-terminal */

int nt = (x >> 8) + NT_OFFSET;

int arrow = x & ((1<<7)-1);

dfa *d1 = PyGrammar_FindDFA(

ps->p_grammar, nt);

if ((err = push(&ps->p_stack, nt, d1,

arrow, lineno, col_offset)) > 0) {

D(printf(" MemError: push/n"));

return err;

}

D(printf(" Push .../n"));

continue;

}

2. 否则,x对应终结符,调用Shift来改变栈顶的状态并把结点添加到CST中。如果栈顶对应着一个Accept状态的话,说明当前DFA已经匹配完毕,反复退栈直到当前状态不为Accept状态为止。

/* Shift the token */

if ((err = shift(&ps->p_stack, type, str,

x, lineno, col_offset)) > 0) {

D(printf(" MemError: shift./n"));

return err;

}

D(printf(" Shift./n"));

/* Pop while we are in an accept-only state */

while (s = &d->d_state

[ps->p_stack.s_top->s_state],

s->s_accept && s->s_narcs == 1) {

D(printf(" DFA '%s', state %d: "

"Direct pop./n",

d->d_name,

ps->p_stack.s_top->s_state));

#ifdef PY_PARSER_REQUIRES_FUTURE_KEYWORD

if (d->d_name[0] == 'i' &&

strcmp(d->d_name,

"import_stmt") == 0)

future_hack(ps);

#endif

s_pop(&ps->p_stack);

if (s_empty(&ps->p_stack)) {

D(printf(" ACCEPT./n"));

return E_DONE;

}

d = ps->p_stack.s_top->s_dfa;

}

return E_OK;

如果没有Acclerator,有可能该结点已经是Accept状态,同样退栈:

if (s->s_accept) {

#ifdef PY_PARSER_REQUIRES_FUTURE_KEYWORD

if (d->d_name[0] == 'i' &&

strcmp(d->d_name, "import_stmt") == 0)

future_hack(ps);

#endif

/* Pop this dfa and try again */

s_pop(&ps->p_stack);

D(printf(" Pop .../n"));

if (s_empty(&ps->p_stack)) {

D(printf(" Error: bottom of stack./n"));

return E_SYNTAX;

}

continue;

}

否则,语法错误,假如可能遇到的token只有一种可能的话,设置expected_ret为当前我们所期望看到的token,这样Python才可以显示语法错误信息。

/* Stuck, report syntax error */

D(printf(" Error./n"));

if (expected_ret) {

if (s->s_lower == s->s_upper - 1) {

/* Only one possible expected token */

*expected_ret = ps->p_grammar->

g_ll.ll_label[s->s_lower].lb_type;

}

else

*expected_ret = -1;

}

return E_SYNTAX;

至此PyParser已经分析完毕。下一篇文章将着重分析从CST到AST转换过程。

作者: ATField
E-Mail: atfield_zhang@hotmail.com
Blog: http://blog.csdn.net/atfield

分享到:
评论

相关推荐

    python 实现SLR(1)语法分析器

    编译原理python 实现SLR(1)语法分析器 包含分支循环结构

    编译原理词法分析器、语法分析器python实现

    python实现的词法分析器和语法分析器,哈工大威海编译原理实现,词法分析器能够识别字符串,能够判断所输入的字符串是否符合文法,语法分析器采用自底向上的LR0实现。

    基于python实现词法分析器、语法分析器和语法制导翻译器.zip

    基于python实现词法分析器、语法分析器和语法制导翻译器 基于Python实现词法分析器、语法分析器和语法制导翻译器的项目适合以下人群: 1. 计算机科学与技术专业的学生:这类项目是编译原理课程的经典实践项目,有助...

    Python Excel数据分析 Python源码

    Python Excel数据分析 Python源码Python Excel数据分析 Python源码Python Excel数据分析 Python源码Python Excel数据分析 Python源码Python Excel数据分析 Python源码Python Excel数据分析 Python源码Python Excel...

    Python上市公司财报分析系统源码.zip

    Python上市公司财报分析系统源码 Python上市公司财报分析系统源码 Python上市公司财报分析系统源码 Python上市公司财报分析系统源码 Python上市公司财报分析系统源码 Python上市公司财报分析系统源码...

    Python 火车票分析助手 Python源码

    Python 火车票分析助手 Python源码Python 火车票分析助手 Python源码Python 火车票分析助手 Python源码Python 火车票分析助手 Python源码Python 火车票分析助手 Python源码Python 火车票分析助手 Python源码Python ...

    Python 对数据分析时判断只能选择Excel或者CSV文件 Python源码

    Python 对数据分析时判断只能选择Excel或者CSV文件 Python源码Python 对数据分析时判断只能选择Excel或者CSV文件 Python源码Python 对数据分析时判断只能选择Excel或者CSV文件 Python源码Python 对数据分析时判断...

    算符优先语法分析器

    完整的实现算符优先语法分析过程的构造,显示,以及规约形式的输出,算法效率很高,用c++语言描述

    Python 实现区域占比分析(饼形图)Python源码

    Python 实现区域占比分析(饼形图)Python源码Python 实现区域占比分析(饼形图)Python源码Python 实现区域占比分析(饼形图)Python源码Python 实现区域占比分析(饼形图)Python源码Python 实现区域占比分析(饼...

    Python 微信好友分析 Python源码

    Python 微信好友分析 Python源码Python 微信好友分析 Python源码Python 微信好友分析 Python源码Python 微信好友分析 Python源码Python 微信好友分析 Python源码Python 微信好友分析 Python源码Python 微信好友分析 ...

    Python 时间序列分析(销售收入增长及季节性波动)Python源码

    Python 时间序列分析(销售收入增长及季节性波动)Python源码Python 时间序列分析(销售收入增长及季节性波动)Python源码Python 时间序列分析(销售收入增长及季节性波动)Python源码Python 时间序列分析(销售收入...

    相关分析 主成分分析 python源码

    相关分析 主成分分析 python源码

    编译原理作业基于python的词法分析、语法分析、 LL1分析器源码+代码注释.zip

    编译原理作业基于python的词法分析、语法分析、 LL1分析器源码+代码注释.zip &lt;项目介绍&gt; 该资源内项目源码是个人的课程是作业,代码都测试ok,都是运行成功后才上传资源,答辩评审平均分达到94.5分,放心下载使用! ...

    Python 产品贡献度分析(柱形图加百分比)Python源码

    Python 产品贡献度分析(柱形图加百分比)Python源码Python 产品贡献度分析(柱形图加百分比)Python源码Python 产品贡献度分析(柱形图加百分比)Python源码Python 产品贡献度分析(柱形图加百分比)Python源码...

    python数据分析课设源码.zip

    python数据分析课设源码.zippython数据分析课设源码.zippython数据分析课设源码.zippython数据分析课设源码.zippython数据分析课设源码.zippython数据分析课设源码.zippython数据分析课设源码.zippython数据分析课设...

    Python 实现的 C 词法分析器

    Python 语言写的 C 语言的词法分析器,是实验报告的一个实验实现,写的很粗糙,简单看看就好了,不保证最终效果

    基于Python实现的语法分析.zip

    资源包含文件:设计报告word+指导书+代码及数据 需求分析 生成LR(1)分析中用到的action和goto表。...对源程序中存在语法错误报错。 详细介绍参考:https://blog.csdn.net/sheziqiong/article/details/125298445

    Python 分析月平均消费金额 Python源码

    Python 分析月平均消费金额 Python源码Python 分析月平均消费金额 Python源码Python 分析月平均消费金额 Python源码Python 分析月平均消费金额 Python源码Python 分析月平均消费金额 Python源码Python 分析月平均...

    编译原理词法分析器和语法分析器实验报告附源码.zip

    编译原理词法分析器和语法分析器实验报告附源码.zip

    Python 影视作品的分析 Python源码

    Python 影视作品的分析 Python源码Python 影视作品的分析 Python源码Python 影视作品的分析 Python源码Python 影视作品的分析 Python源码Python 影视作品的分析 Python源码Python 影视作品的分析 Python源码Python ...

Global site tag (gtag.js) - Google Analytics