Python对象表示法
项目描述
Python对象表示法(PON)满足Python需要简单、可读、可维护的配置数据文件格式的需求。
一个基本的PON文件示例
dict( version = [1, 2, 3], # Major, Minor and Micro version number email = "avdn@europython.org", score = 8.7, restrictions = None, published = True, install_requires=dict( # nested structure any=['requests', 'beautifulsoup'], py26=['ordereddict'], ), oddkeys = { "2": 2, "s p a c e d": "out"} )
PON
对所有Python程序员来说都是可读和可维护的
允许注释
可以由人类编辑而不会每次都出问题
可以有效地和安全地加载
具有几种基本类型:字符串、数字(包括简单的数学运算)、True、False、None、date()、datetime(UTC,无时区信息),
基本结构:字典(dict() / {})和列表([]),
扩展结构:集合(set() / {}(后者仅在2.7+中存在)和元组(( ))
基本和扩展结构可以嵌套(在其自身类型和其他类型中),但有以下限制
顶级必须是字典
没有自我引用的语法
{}只能有字符串作为键
扩展结构(集合、元组)的子节点不能用于替换
在字符串值上有替换/插值,替换值是可能复杂值的str()结果
可以作为有效的Python代码嵌入到Python文件中,并提供提取此类嵌入配置的例程。
期望基于UTF-8的输入
支持通过正常的三引号来处理多行字符串,以及通过dedent来处理这些多行字符串
具有足够小的内存占用,不需要在您使用之前安装任何包(例如,PyPI上几个setup.py文件中的约100行解析器)的可读只解析器
在少数限制下,支持在保留注释和仔细排序的键的情况下来回转换
输出符合PEP8规范,除了允许(但不要求)在dict()键之后的=周围有空格
为什么不是YAML/XML/INI-CFG/JSON
YAML
YAML满足PON的所有要求,除了在值上的字符串替换。它还可以完成更复杂的任务,如处理(自我)引用和复杂的字典/映射键。然而,其基本格式易于阅读,但并不是其所有语法变体都为人所熟悉。
主要问题是它需要外部库(YAML1.2的ruamel.yaml(保留注释的来回转换),PyYAML的1.1版本(保证注释丢失))以及例如不能在setup.py中使用。
XML
Python自带XML解析器,因此从任何程序以及从setup.py中使用都是可能的。XML结合了ASCII的无效性和二进制的不可读性。"够说了"。
INI
INI/CFG文件对很多人来说很熟悉,但遗憾的是,存在各种各样的变体和扩展。Python的内置configparser(在 < Python 3.0 中为ConfigParser),只提供有限的结构信息。它基本上是一个字典,其中的部分作为键,值是字典(键 = 值对)。它以传统方式支持多行字符串(缩进续行符)、注释,并且有两种不同的插值形式。
JSON
JSON是对很多人来说都很熟悉的数据交换格式。然而,有几件事情使得它不适合由人类操作的配置文件
在大多数实现中无法处理注释(包括Python的json模块),尽管发明者指定这些应该被忽略
人类在每次编辑时都会生成一个无效的文件,因为逗号是分隔符,不允许在列表/字典终端] resp. }之前
JSON要求即使是简单的字典键(没有空格的字符串)也必须通过引号进行混淆
Python已经包含了PON的大部分电池组件
在Python中,您已经可以做到
config = dict( version = [1, 2, 3], # Major, Minor and Micro version number email = "avdn@europython.org", score = 8.7, restrictions = None, published = True, install_requires=dict( # nested structure any=['requests', 'beautifulsoup',], py26=['ordereddict'] ), )
这是一个有效的Python代码,嵌入要求是隐式处理的。问题是您并不总是想以代码的形式评估/包含/导入这个,因为存在安全问题。您需要能够解析它并拒绝无效或危险的构造。
(注意,您可以在上面的例子中删除包含py26=的整个行,而不会破坏任何事情,尽管您最终会在闭括号之前得到一个逗号。)
直接解析PON(几乎)
来自ast模块的literal_eval函数可以解析之前配置的更类似于JSON的变体。例如,以下文件的内容
{ "version": [1, 2, 3], # Major, Minor and Micro version number "email": "avdn@europython.org", "score": 8.7, "restrictions": None, "published": True, "install_requires": { # nested structure "any": ['requests', 'beautifulsoup',], "py26": ['ordereddict'] }, }
使用以下
python -c 'import ast; ast.literal_eval(open("input2.pon").read())
上述内容也可以通过在较大的(Python源代码)文件中寻找对已知变量(如 config = {)的赋值,以及对应的 }(通常位于相同的缩进级别)来相对容易地解析。
这几乎就是JSON,但要使Python能够包含JSON并解析它,你还需要做更多修改。
true = True null = None config = { "version": [1, 2, 3], "email": "avdn@europython.org", "score": 8.7, "restrictions": null, "published": true, "install_requires": { "any": ["requests", "beautifulsoup",], "py26": ["ordereddict"] } }
你需要为Python解析器定义 null 和 true,对于大多数JSON解析器,你还需要删除注释,并始终使用双引号来表示字符串。最重要的是,你必须删除尾随逗号,这在删除JSON中字典/映射末尾的整个键值行时最容易被遗忘(除非你使用 ruamel.yaml/PyYAML 来加载你的JSON文件,否则会导致程序无法运行)。
实际上,上述内容不是有效的JSON(你看到 any 行上的尾随逗号了吗?)。这些问题并不使JSON成为一个糟糕的格式。它适用于程序之间的信息交换。JSON文件绝对不应该被编辑,甚至最好也不需要由人类读取。
literal_eval 的替代品
ast.literal_eval 不能处理 dict(),因此使用它时,你不能有不带引号的字符串键。当它接收无效字符串时,它还会抛出一个无用的通用 ValueError,这使得向(无效的)配置数据的人类编辑者提供有意义的反馈变得困难。最后,当它接收诸如浮点数之类的无意义数据时,它会愉快地尝试并失败。
ast.literal_eval 是如何围绕 ast 功能制作最小评估器的良好例子。经过少量调整后,它可以处理额外的内容,如 dict、date 和 datetime。因此,允许非引号简单键,同时禁止非字符串键用于 {},强制使用顶层字典。代码包括针对2.6及以后版本的支持(2.6的无法处理 {} 类型集合)的调整。
import sys # NOQA import platform # NOQA import datetime # NOQA from textwrap import dedent # NOQA from _ast import * # NOQA if sys.version_info < (3, ): string_type = basestring else: string_type = str if sys.version_info < (3, 4): class Bytes(): pass class NameConstant: pass if sys.version_info < (2, 7) or platform.python_implementation() == 'Jython': class Set(): pass def loads(node_or_string, dict_typ=dict, return_ast=False, file_name=None): """ Safely evaluate an expression node or a string containing a Python expression. The string or node provided may only consist of the following Python literal structures: strings, bytes, numbers, tuples, lists, dicts, sets, booleans, and None. """ if sys.version_info < (3, 4): _safe_names = {'None': None, 'True': True, 'False': False} if isinstance(node_or_string, string_type): node_or_string = compile( node_or_string, '<string>' if file_name is None else file_name, 'eval', PyCF_ONLY_AST) if isinstance(node_or_string, Expression): node_or_string = node_or_string.body else: raise TypeError("only string or AST nodes supported") def _convert(node, expect_string=False): if isinstance(node, (Str, Bytes)): return node.s if expect_string: pass elif isinstance(node, Num): return node.n elif isinstance(node, Tuple): return tuple(map(_convert, node.elts)) elif isinstance(node, List): return list(map(_convert, node.elts)) elif isinstance(node, Set): return set(map(_convert, node.elts)) elif isinstance(node, Dict): return dict_typ((_convert(k, expect_string=True), _convert(v)) for k, v in zip(node.keys, node.values)) elif isinstance(node, NameConstant): return node.value elif sys.version_info < (3, 4) and isinstance(node, Name): if node.id in _safe_names: return _safe_names[node.id] elif isinstance(node, UnaryOp) and \ isinstance(node.op, (UAdd, USub)) and \ isinstance(node.operand, (Num, UnaryOp, BinOp)): # NOQA operand = _convert(node.operand) if isinstance(node.op, UAdd): return + operand else: return - operand elif isinstance(node, BinOp) and \ isinstance(node.op, (Add, Sub, Mult)) and \ isinstance(node.right, (Num, UnaryOp, BinOp)) and \ isinstance(node.left, (Num, UnaryOp, BinOp)): # NOQA left = _convert(node.left) right = _convert(node.right) if isinstance(node.op, Add): return left + right elif isinstance(node.op, Mult): return left * right else: return left - right elif isinstance(node, Call): func_id = getattr(node.func, 'id', None) if func_id == 'dict': return dict_typ((k.arg, _convert(k.value)) for k in node.keywords) elif func_id == 'set': return set(_convert(node.args[0])) elif func_id == 'date': return datetime.date(*[_convert(k) for k in node.args]) elif func_id == 'datetime': return datetime.datetime(*[_convert(k) for k in node.args]) elif func_id == 'dedent': return dedent(*[_convert(k) for k in node.args]) elif isinstance(node, Name): return node.s err = SyntaxError('malformed node or string: ' + repr(node)) err.filename = '<string>' err.lineno = node.lineno err.offset = node.col_offset err.text = repr(node) err.node = node raise err res = _convert(node_or_string) if not isinstance(res, dict_typ): raise SyntaxError("Top level must be dict not " + repr(type(res))) if return_ast: return res, node_or_string return res
上述109行Python 确实是 加载整个PON的可迭代对象的实际代码。
如果你只需要支持较新的Python版本,并且知道你的输入是受限的(没有数学,没有 set/tuples/{},没有 datetime 等),则可以进一步减少这段代码。
语法错误
ast.literal_eval 会抛出一个通用的 ValueError,没有任何关于可能出错的地方或错误的指示。从由 loads() 在错误输入上抛出的 SyntaxError 中,你可以检索有用的行信息。
error_str = u""" dict( a= u"α", b= False, c= date(2015, 9, 12), d= 1.37, ) """ try: loads(error_str) except SyntaxError as e: context = 2 from_line = e.lineno - (context + 1) to_line = e.lineno + (context - 1) w = len(str(to_line)) for index, line in enumerate(error_str.splitlines(True)): if from_line <= index <= to_line: print(u"{:{}}: {}".format(index, w, line), end=u'') if index == e.lineno - 1: print(u"{:{}} {}^--- {}".format( u' ', w, u' ' * e.offset, e.node))
给出
2: a= u"α", 3: b= False, 4: c= date(2015, 9, 12), ^--- <_ast.Call object at 0x7f1598d20950> 5: d= 1.37, 6: )
(如上所述,PON解析器通过添加 date() 扩展了 ast.literal_eval,并且在该输入上不会抛出错误)
动机
开发 literal_eval 扩展/替代品是由从包的 __init__.py 文件中清理版本和其他信息到其 setup.py 文件所激发的,从而最小化了基础目录中额外配置文件的杂乱(setup.py、dist 和 tox.ini 作为非隐藏文件/目录已经足够糟糕)。
版本号可以很容易地从 __init__.py 文件中解析出来。但是,允许更复杂和完整的配置数据可以使 setup.py 在我的所有项目中保持一致。
使用 pon 包
pon 包提供了主要的解析器 loads()、实用函数 get()、store() 和 extract() 以及 PON 类(这些实用函数是快捷方式)。
get() 和 store()
如果你将配置
dict( a = dict( b = 24, c = [1, 3.14, {'d': 'klm'}], }
加载到变量 config 中,你可以使用常规的 Python 语法通过 config['a']['c'][2]['d'] 访问值 klm。PON 还提供了函数 get(),你可以使用 get(config, 'a.c.2.d') 访问相同的值。
基于 config 的嵌套结构,序列中的“2”被转换为索引。如前所述,不允许整数作为字典键,键必须是字符串。
相应地,还有 store() 函数(Python 中 set() 是保留字)作为第三个参数接受一个值,用于设置或覆盖现有值: store(config, 'a.c.2.d', 'xyz')
使用 get() 替换
替换(在 ConfigParser/configparser 中称为插值)是通过使用 get() 访问配置中的值并使用额外的关键字 expand 来完成的。替换是在展开值上递归执行的。你可以提供 config 对象本身来展开
val = get(config, 'some.path', expand=config)
由于这是一个非常常见的用例,你可以指定 expand=True 而不是实际传递两次 config。
替换的语法是常规的 Python,{key}".format(key=value),字符串格式化,但键可以是 get() 有效的点分隔序列
import pon config = pon.loads("""\ dict( a = dict( image = "http://{domain}/images", alt = "europython.eu", dd = (2011, 10, 2) # this is a tuple ), domain = 'python.{tld.organisations}', datestr = 'date{a.dd}', tld = {"organisations": "org", "commmercian": "com"} ) """) for key in ['a.image', 'datestr']: print(key, '->', pon.get(config, key, expand=True))
给出
a.image -> https://pythonlang.cn/images datestr -> date(2011, 10, 2)
这个递归的限制为 10 级。
分隔符(默认为“.”)可以在 PON 类中设置。由于“:”在格式字符串中是特殊的,所以不能用作分隔符。
PON 的往返操作
在有些限制下,可以在保持注释的情况下对 PON 进行往返操作,就像 ruamel.yaml 可以对 YAML 做的那样
你不会在第一次往返操作中丢失任何数据
第一次往返操作可能改变格式
第二次往返操作将产生与第一次往返操作相同的结果
为了方便往返操作,需要保留一些额外的信息,这些信息在将你的 PON 数据结构加载到 Python 的常规字典中时是不可用的。这种额外的处理可以在前端完成,之后原始配置数据就不再以文本形式必要,但如果往返操作永远不会需要,这会浪费加载时间。这些信息可以按需提取,但在那种情况下,需要提供原始的文本数据。这种时间和存储的权衡目前是在加载时进行的,并且仅当使用 PON 类(而不是使用实用函数 loads())时。
如果你使用 PON(input) 从 input(一个文件、一个字符串或字符串的列表)创建一个 PON 对象,生成的对象将有关字典键和列表元素的信息以及与这些键关联的注释信息。
往返操作的主要目的是更新配置中的现有信息:更新“version”键的元组值,为 py26 添加依赖包。如果需要生成包括注释在内的整个新的配置文件,通常使用(或从)文本模板开始比尝试程序性地构建结构要容易得多。
第一轮往返变化
部分原因是基于dumper的pprint代码,部分原因是关于保留哪种格式化信息的任意决策,部分取决于您的输入,以下是在第一次往返中发生的情况
多行字典/列表的最后一个元素后面有一个尾随逗号
无法与同一行上的字典键或列表元素关联的注释会被移动到下一个键/元素(如果没有后续元素,则为主字典的末尾)
除非字符串包含单引号(且没有双引号),否则字符串使用单引号
缩进级别为4个空格
在字典键和值之间的等号周围的空格被删除
集合和元组的元素不能与注释关联,因此如果它们不在与字典键/列表元素同一行,注释就会游走
集合和元组在同一行上导出,任何位于其下的字典和行目前对get()不可访问,因此此类字典/列表的键/元素不能与注释关联
带有空白符的额外行会静默删除
加/减/乘不被保留
datetime的repr()如果毫秒数等于零,则删除尾随的毫秒数,如果秒数等于零,则删除秒数。
除非您操作字典键和/或列表长度,否则不会丢失数据或注释。如果将导出的输出作为源,则不应有进一步的“游走”。以下输入
try: from cStringIO import StringIO as _StringIO except ImportError: from io import StringIO as _StringIO from pon import PON input = """ dict( pckgs = dict( any=['package1', 'package2'], py26=['another package', 'and one with a long name', 'and on a new line'] # where do you go? ), ) """ out1 = _StringIO() p1 = PON(input) p1.dump(out1) print(out1.getvalue()) out2 = _StringIO() p2 = PON(out1.getvalue()) p2.dump(out2) print('roundtrip 1: {0}, roundtrip 2: {1}'.format( input == out1.getvalue(), out1.getvalue() == out2.getvalue()))
给出以下输出
dict( pckgs=dict( any=['package1', 'package2'], py26=['another package', 'and one with a long name', 'and on a new line', # where do you go? ], ), ) roundtrip 1: False, roundtrip 2: True
进一步改进
集合和元组元素可以索引,然后注释可以与它们的元素关联,并且多行导出会得到更好的保留。
dump()可以接受有关缩进深度和字符串引号信息的参数。
在原则上,dump() 输出应遵循 PEP8 标准。但在我看来,在多行关键字参数赋值中移除 dict() 周围的空格并不会使代码更易读。提供一个参数来选择一种或另一种格式将会很有用。
dict( a='1234324', b=['xyz', 'klm'], )
比
dict( a = '1234324', b = ['xyz', 'klm'], )
保持多行连续注释的 # 对齐,即使是在导出前修改了值并且其长度已变长。
展示
以下程序包含大多数(如果不是所有)的功能和往返操作
from io import StringIO as _StringIO from pon import PON configs = u'''\ # example config # should contain all types and facilities dict( s='abc', # single line string # multiline string mls="""one two three""", mls_dedent=dedent(""" abc def """), ghi={'A': 1, 'B': 2}, klm=['Airbus 370', 'Fokker 100'], opq=set([2, 3, 5, 7, 9]), rst=(0, 1, 1, 2, 3, 5, 8, 13), # Fibonacci m={u'π': 3.14}, anniversary=date(2011, 10, 2), dts=datetime(1919, 12, 1, 13, 45, 4), milisec=datetime(1922, 10, 19, 17, 55, 23, 321), six=2 + 4, secs_per_day=24 * 60 * 60, two=-2 - -4, # if you want to extend, do it here ) # and it's over ''' out = _StringIO() p = PON(configs) p.dump(out) conf_adjust_for_calc = configs # calculations are not preserved, they don't round trip, so adjust here for x, y in (('2 + 4', '6'), ('24 * 60 * 60', "{}".format(24 * 60 * 60)), ('-2 - -4', '2')): conf_adjust_for_calc = conf_adjust_for_calc.replace(x, y) outl = out.getvalue().splitlines(True) orgl = conf_adjust_for_calc.splitlines(True) if outl == orgl: print('roundtrip 1: equal') else: import difflib diff = difflib.unified_diff(orgl, outl, 'input', 'output') for line in diff: print(line, end='')
输出
--- input +++ output @@ -3,9 +3,7 @@ dict( s='abc', # single line string # multiline string - mls="""one - two - three""", + mls='one\n two\n three', mls_dedent=dedent(""" abc def
项目详情
pon-0.2.5.tar.gz 的散列值
算法 | 散列摘要 | |
---|---|---|
SHA256 | 7cbc79e5c08df5323a2725cd1e8339ecd565f4ec21a3fa371b59df574692ecea |
|
MD5 | c7eb9b805f918476dd40be5e300a3330 |
|
BLAKE2b-256 | 12ea51ec3e2a3339ba8d8bd6f507dddd9b03d4c7fc89504e54f508d8817cbee6 |
注释
以文本形式导出加载的PON结构相对简单,如果我们忽略注释对将来读者的重要性。
Python内置的compile()函数从AST信息中生成,用于提取包含配置信息的对象,会丢弃注释。因此,必须将注释信息重新关联到对象上,除了确定哪些注释属于哪里,还需要对象树中的元素可以被扩展(需要像dict/list一样行为但具有额外注释信息插槽的更复杂对象,这种方法也用于ruamel.yaml),或者保持与配置对象相同形式的阴影结构。
PON通过要求字典具有键插入顺序(源配置数据中的键的顺序)以及保持阴影结构来遵循混合模式。阴影结构从AST树(用于生成/检查加载的配置)中提取,带有标记信息(提供注释)。
只要注释“单独一行”,注释就会与字典键或列表元素关联。一个完整的行注释属于或连续的注释属于下一个键/元素,如果它是在上一个键/元素之后的单独一行。行尾注释跟在键/元素行尾(并且只能有一个)。此外,还会跟踪初始的、顶层的dict(之前、最后一个键之后(没有跟随的键可以将其钩住)以及)标记之后的内容。
一个大量注释的PON文件示例
如果您更改字典键,通常与这些键关联的注释会丢失。与被删除的键/元素关联的注释也是如此。