Python 优秀开源项目 Rich 源码解析

语言: CN / TW / HK

:point_up_2:  Python猫 ” ,一个值得加星标的 公众号

剧照|《三国机密之潜龙在渊》

来源:渡码@渡码公众号

这篇文章对优秀的开源项目 Rich 的源码进行解析, OMG,盘他 。为什么建议阅读源码,有两个原因,第一,单纯学语言很难在实践中灵活应用,通过阅读源码可以看到每个知识点的运用场景,印象会更深,以后写代码的时候就能应用起来;第二,通过阅读优秀的开源代码,可以学习比人的代码规范、设计思路;第三,参与到开源社区,获得更广阔的的发展前景;第四,面试加分项。所以,有时间的话还是建议大家多读读优秀开源项目的源码。

下面进入今天的主题,这个开源项目的名字叫 Rich ,将近8k star,地址:https://github.com/willmcgugan/rich (可以点击文末 阅读原文 查看)。这个项目是个英国老铁开发的,比较友好的是有中文文档。它的作用是可以在控制台输出富文本和精美的可视化格式(如:表格、进度条和markdown)。截图感受一下

各种格式

进度条

效果看起来很酷炫,我忍不住看了一些代码,发现作者用的是 Python 3.8版本实现的,好多新特性我也不了解,所以在看源码过程中还补了一下语法基础。下面以一个例子来简单看看 Rich 的源码,源码的讲解我尽量言简意赅,重点讲解源码中涉及的一些关键的知识点。

先捡个软柿子捏,如下:

from rich import print

print('Hello, [bold yellow]World[/bold yellow]!')

输出效果:

可以看到对单词 World 显示为粗体、红颜色。

先通过一张图来看看大致流程

简单来说就是将文本的格式转化成标准输出能够识别的格式,然后输出即可。下面来讲解源码,当我们调用 print 函数时,最终程序会跳转到 console.py 文件的 print 函数中,执行以下代码

调用 self._collect_renderables 函数处理输入的字符串,将需要格式化的部分标出来,返回的 renderables 变量是一个 Text 列表,因为输入只有1个字符串,所以列表的大小为1,变量结果如下

Span(7, 12, 'bold red') 便是框出来需要格式化的内容。

上述代码还有一个 with self ,它的作用我们一会儿再说。接着 print 函数往下看

这里会遍历刚刚提到的 renderables 变量,先调用 render 函数渲染输入的文本,然后调用 extend 函数将 render 返回的结果添加到 self._buffer 列表里。这里有几个知识点简单说一下

  • self._buffer
    @property
    self._thread_locals.buffer
    List[Segment]
    
  • self._thread_locals.buffer
    dataclasses
    field
    buffer: List[Segment] = field(default_factory=list)
    dataclasses
    Python
    field
    @dataclass
    __init__
    
  • extend = self._buffer.extend
    list
    extent
    extend
    对象名.extend
    

下面我们来看 render(renderable, render_options) 函数的渲染逻辑,该函数里会调用下面的代码

render_iterable = renderable.__rich_console__(self, options)

在函数声明里 renderable 对象是 RenderableType 类型的,但实际上 Text 类型的,并且这两种类型没有继承关系,这里没太想明白作者为什么这样搞。所以,这里的 __rich_console__ 函数我们要到 text.py 文件中去找。 __rich_console__ 函数最终会调用 Text 对象的 render 函数,核心代码如下:

def render(self, console: "Console", end: str = "") -> Iterable["Segment"]:
  style_map = {index: get_style(span.style) for index, span in enumerated_spans}

  _Segment = Segment

  for (offset, leaving, style_id), (next_offset, _, _) in zip(spans, spans[1:]):
    yield _Segment(text[offset:next_offset], get_current_style())

调用 get_style 函数,将格式转为 Style 对象,如:'bold red'转成 Style 对象,然后按照不同的显示格式进行‘分片’,每个‘片段’构造一个 Segment 对象存储文本及其对应的格式。

get_style 函数会调用 Style.parse(name) 生成 Style 对象,核心代码如下

@lru_cache(maxsize=1024)
def parse(cls, style_definition: str) -> "Style":
  words = iter(style_definition.split())
  for original_word in words:
    word = original_word.lower()
    if word == "on":
      # ...省略
    elif word in style_attributes:
      attributes[style_attributes[word]] = True
    else:
      color = word
  style = Style(color=color, bgcolor=bgcolor, link=link, **attributes)
  return style

参数 style_definition 取值为 bold red ,分割后生成['bold', 'red']列表,当 word 变量等于'bold'时,会执行 attributes[style_attributes[word]] = True 语句,执行后 attributes 等于 {'bold': true} ,它是一个字典。当 word 变量等于 red 时,执行 color=word 语句。最终调用导数第二行构造 Style 对象, Style 对象最核心的两个数据形式 _attributes_color , 前者是 int 类型,在我们例子中取值是1,代表'bold',即:粗体。后者代表颜色,即:'red',它是 Color 类型的,该类中有个属性 number 也是我们后续要用到的。

下面来看下 __rich_console__ 函数返回了哪些 Segment 对象

可以看到有4个,每一个都有文本及其 Style 对象。

回到 render(renderable, render_options) 函数,刚刚介绍了 __rich_console__ 部分,下面还有返回的代码, 一起来看看

iter_render = iter(render_iterable)
for render_output in iter_render:
  if isinstance(render_output, Segment):
    yield render_output

render_iterable 变量是 __rich_console__ 的返回值,即:4个 Segment 对象。遍历后通过 yield 方式返回。该关键字用来返回一个迭代器,也可以理解为一个列表。并且 yield 返回有个特点,函数返回值只有真正被使用的时候才会执行调用函数。

这样, render(renderable, render_options) 函数就讲解完了,返回上一层 extend(render(renderable, render_options)) ,通过 extend 函数将4个 Segment 对象保存到 buffer 中,结果如下

然后 print 方法就执行完了。看起来已经结束了,然而控制台打印的代码貌似没有看到。答案就在刚刚的 with self 中, with 关键字使得执行完代码体后,会自动调用 self__exit__ 函数。 __exit__ 函数中调用 _render_buffer 函数进行最终的输出,核心代码如下

output: List[str] = []
append = output.append
for line in Segment.split_and_crop_lines(buffer, self.width, pad=False):
    for text, style, is_control in line:
        if style and not is_control:
            append(
                style.render(
                    text,
                    color_system=color_system,
                    legacy_windows=legacy_windows,
                )
            )
rendered = "".join(output)

return rendered

split_and_crop_lines 函数是为了适应控制台的宽度,暂时忽略它。 line 变量仍然是刚刚提到的4个 Segment 对象,通过 for text, style, is_control in line 直接将每个 Segment 对象的属性解出来并赋给 text, style, is_control 变量,最终每个 style 对象都会调用 render 方法完成最后的渲染。

render 方法核心代码如下

attrs = self._make_ansi_codes(color_system)
rendered = f"\x1b[{attrs}m{text}\x1b[0m" if attrs else text

_make_ansi_codes 函数就不展开了, 其实就是利用上面提到的 _attributesnumber 属性生成标准输出的能够识别的格式,返回值 attrs 的结果为 1;31 ,1取自 _attributes 代表粗体,31中的1取自 number 代表颜色,其他颜色取值是不同的,比如黄色是33,紫色是35。最后通过 f-string 格式(新特性)生成 rendered 变量,取值为 [1;31mWorld[0m 它就是标准输出流能够识别的格式。

回到 _render_buffer 函数中,调用 rendered = "".join(output) 将4个渲染后的片段拼在一起,返回。返回后执行的代码如下:

text = self._render_buffer()
if text:
    self.file.write(text)

self.file 变量的赋值语句为 self.file = file or sys.stdout ,由于我们没有定义 file 变量,所以 self.file 取值为 sys.stdout 。最终的输出为 sys.stdout.write(text) ,至此整个流程就讲解完了。如果你理解了上述逻辑,应该可以通过下面代码输出同样的效果

sys.stdout.write('Hello, \033[1;31mWorld\033[0m!')

所以 Rich 做的就是把文字格式准成标准输出流能识别的格式。

Rich 里用到的代码确实挺新的,能学到很多东西,比直接看书来的快,有兴趣的朋友可以自行阅读。经常读我文章的朋友知道,我一直在寻找新的内容、新方向,这次源码解析也是一次新的尝试,不知道是不是一件有价值的事情,先持续更新几篇看看。如果你觉得有用也想看更多的源码解析的文章,希望点个赞或者在看鼓励一下,不胜感激。

分享到: