Pyjoyment 0.1.0__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.
- Pyjo/App/ASGI.py +735 -0
- Pyjo/App/CGI.py +773 -0
- Pyjo/App/FastCGI.py +631 -0
- Pyjo/App/Lambda.py +987 -0
- Pyjo/App/WSGI.py +427 -0
- Pyjo/App/__init__.py +409 -0
- Pyjo/Asset/File.py +377 -0
- Pyjo/Asset/Memory.py +235 -0
- Pyjo/Asset/__init__.py +200 -0
- Pyjo/Base.py +215 -0
- Pyjo/Cache.py +119 -0
- Pyjo/Collection.py +570 -0
- Pyjo/Content/MultiPart.py +325 -0
- Pyjo/Content/Single.py +193 -0
- Pyjo/Content/__init__.py +806 -0
- Pyjo/Cookie/Request.py +90 -0
- Pyjo/Cookie/Response.py +294 -0
- Pyjo/Cookie/__init__.py +143 -0
- Pyjo/DOM/CSS.py +1019 -0
- Pyjo/DOM/HTML.py +550 -0
- Pyjo/DOM/__init__.py +1329 -0
- Pyjo/Date.py +294 -0
- Pyjo/EventEmitter.py +522 -0
- Pyjo/Exception.py +494 -0
- Pyjo/File.py +959 -0
- Pyjo/GetOpt.py +605 -0
- Pyjo/Headers.py +760 -0
- Pyjo/HelloWorld.py +77 -0
- Pyjo/Home.py +109 -0
- Pyjo/IOLoop/ChildProcess.py +591 -0
- Pyjo/IOLoop/Client.py +736 -0
- Pyjo/IOLoop/Datagram.py +982 -0
- Pyjo/IOLoop/Messages.py +664 -0
- Pyjo/IOLoop/Server.py +458 -0
- Pyjo/IOLoop/Stdio.py +446 -0
- Pyjo/IOLoop/Stream.py +644 -0
- Pyjo/IOLoop/Subprocess.py +419 -0
- Pyjo/IOLoop/TLS.py +360 -0
- Pyjo/IOLoop/__init__.py +1036 -0
- Pyjo/IOLoop/certs/README.md +41 -0
- Pyjo/IOLoop/certs/ca.crt +20 -0
- Pyjo/IOLoop/certs/server.crt +21 -0
- Pyjo/IOLoop/certs/server.key +28 -0
- Pyjo/JSON/Pointer.py +135 -0
- Pyjo/JSON/RPC.py +772 -0
- Pyjo/JSON/Schema.py +2506 -0
- Pyjo/JSON/__init__.py +573 -0
- Pyjo/Loader.py +342 -0
- Pyjo/Log.py +723 -0
- Pyjo/Message/Request.py +725 -0
- Pyjo/Message/Response.py +476 -0
- Pyjo/Message/__init__.py +830 -0
- Pyjo/Parameters.py +472 -0
- Pyjo/Path.py +419 -0
- Pyjo/Promise.py +894 -0
- Pyjo/Prove.py +1305 -0
- Pyjo/Reactor/Asyncio.py +854 -0
- Pyjo/Reactor/Poll.py +439 -0
- Pyjo/Reactor/__init__.py +525 -0
- Pyjo/SSE.py +119 -0
- Pyjo/Server/ASGI.py +486 -0
- Pyjo/Server/CGI.py +259 -0
- Pyjo/Server/Daemon.py +1007 -0
- Pyjo/Server/FastCGI.py +517 -0
- Pyjo/Server/Hot/__init__.py +687 -0
- Pyjo/Server/Hot/__main__.py +5 -0
- Pyjo/Server/Lambda.py +641 -0
- Pyjo/Server/Prefork.py +917 -0
- Pyjo/Server/Prethread.py +394 -0
- Pyjo/Server/Reload/Backend/Poll.py +114 -0
- Pyjo/Server/Reload/Backend/__init__.py +126 -0
- Pyjo/Server/Reload/__init__.py +482 -0
- Pyjo/Server/Reload/__main__.py +5 -0
- Pyjo/Server/Stdio.py +228 -0
- Pyjo/Server/WSGI.py +349 -0
- Pyjo/Server/__init__.py +396 -0
- Pyjo/String/Bytes.py +532 -0
- Pyjo/String/HTML.py +210 -0
- Pyjo/String/Mixin.py +333 -0
- Pyjo/String/Template.py +470 -0
- Pyjo/String/Unicode.py +553 -0
- Pyjo/String/__init__.py +0 -0
- Pyjo/TOML.py +232 -0
- Pyjo/Template.py +1290 -0
- Pyjo/Test/Pyjo.py +1454 -0
- Pyjo/Test/__init__.py +1233 -0
- Pyjo/Test/pytest_plugin.py +305 -0
- Pyjo/Transaction/HTTP.py +374 -0
- Pyjo/Transaction/WebSocket.py +709 -0
- Pyjo/Transaction/__init__.py +429 -0
- Pyjo/URL/Template.py +459 -0
- Pyjo/URL/__init__.py +651 -0
- Pyjo/Upload.py +139 -0
- Pyjo/UserAgent/CookieJar.py +391 -0
- Pyjo/UserAgent/Proxy.py +138 -0
- Pyjo/UserAgent/Server.py +239 -0
- Pyjo/UserAgent/Transactor.py +766 -0
- Pyjo/UserAgent/__init__.py +1243 -0
- Pyjo/Util.py +3567 -0
- Pyjo/WebSocket.py +332 -0
- Pyjo/YAML.py +1167 -0
- Pyjo/__init__.py +441 -0
- Pyjo/__main__.py +35 -0
- Pyjo/py.typed +0 -0
- Pyjoyment/Command/Author/__init__.py +20 -0
- Pyjoyment/Command/Author/generate/__init__.py +106 -0
- Pyjoyment/Command/Author/generate/app.py +299 -0
- Pyjoyment/Command/Author/generate/dockerfile.py +281 -0
- Pyjoyment/Command/Author/generate/kubernetes.py +514 -0
- Pyjoyment/Command/Author/generate/lambda_zip.py +372 -0
- Pyjoyment/Command/Author/generate/lite_app.py +113 -0
- Pyjoyment/Command/Author/generate/plugin.py +267 -0
- Pyjoyment/Command/Author/generate/pyproject.py +123 -0
- Pyjoyment/Command/Author/inflate.py +101 -0
- Pyjoyment/Command/__init__.py +332 -0
- Pyjoyment/Command/asgi.py +92 -0
- Pyjoyment/Command/cgi.py +90 -0
- Pyjoyment/Command/daemon.py +160 -0
- Pyjoyment/Command/eval.py +128 -0
- Pyjoyment/Command/fastcgi.py +99 -0
- Pyjoyment/Command/get.py +305 -0
- Pyjoyment/Command/lambda.py +117 -0
- Pyjoyment/Command/prefork.py +181 -0
- Pyjoyment/Command/prethread.py +126 -0
- Pyjoyment/Command/routes.py +133 -0
- Pyjoyment/Command/stdio.py +92 -0
- Pyjoyment/Command/version.py +141 -0
- Pyjoyment/Command/wsgi.py +93 -0
- Pyjoyment/Commands.py +546 -0
- Pyjoyment/Controller.py +1307 -0
- Pyjoyment/HelloWorld.py +56 -0
- Pyjoyment/Lite.py +494 -0
- Pyjoyment/Plugin/AllowedHosts.py +205 -0
- Pyjoyment/Plugin/Config.py +248 -0
- Pyjoyment/Plugin/DefaultHelpers.py +1202 -0
- Pyjoyment/Plugin/EPLRenderer.py +134 -0
- Pyjoyment/Plugin/EPRenderer.py +212 -0
- Pyjoyment/Plugin/HeaderCondition.py +98 -0
- Pyjoyment/Plugin/Health.py +325 -0
- Pyjoyment/Plugin/JSONConfig.py +172 -0
- Pyjoyment/Plugin/Mount.py +223 -0
- Pyjoyment/Plugin/PyRenderer.py +209 -0
- Pyjoyment/Plugin/TOMLConfig.py +141 -0
- Pyjoyment/Plugin/TagHelpers.py +959 -0
- Pyjoyment/Plugin/YAMLConfig.py +178 -0
- Pyjoyment/Plugin/__init__.py +80 -0
- Pyjoyment/Plugins.py +215 -0
- Pyjoyment/Renderer.py +673 -0
- Pyjoyment/Routes/Match.py +251 -0
- Pyjoyment/Routes/Pattern.py +443 -0
- Pyjoyment/Routes/Route.py +827 -0
- Pyjoyment/Routes/__init__.py +419 -0
- Pyjoyment/Sessions.py +255 -0
- Pyjoyment/Static.py +421 -0
- Pyjoyment/Types.py +238 -0
- Pyjoyment/Validator/Validation.py +395 -0
- Pyjoyment/Validator/__init__.py +304 -0
- Pyjoyment/__init__.py +1083 -0
- Pyjoyment/__main__.py +5 -0
- Pyjoyment/py.typed +0 -0
- Pyjoyment/resources/public/favicon.ico +0 -0
- Pyjoyment/resources/public/favicon.svg +345 -0
- Pyjoyment/resources/public/pyjo/bootstrap/LICENSE +21 -0
- Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.bundle.min.js.map +1 -0
- Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.css +6 -0
- Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.js +7 -0
- Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.min.css.map +1 -0
- Pyjoyment/resources/public/pyjo/failunicat.png +0 -0
- Pyjoyment/resources/public/pyjo/failunicat.svg +50 -0
- Pyjoyment/resources/public/pyjo/highlight.js/LICENSE +29 -0
- Pyjoyment/resources/public/pyjo/highlight.js/highlight-mojo-dark.css +56 -0
- Pyjoyment/resources/public/pyjo/highlight.js/highlight.min.js +1262 -0
- Pyjoyment/resources/public/pyjo/highlight.js/pyjoyment.min.js +1 -0
- Pyjoyment/resources/public/pyjo/logo-black-4x.png +0 -0
- Pyjoyment/resources/public/pyjo/logo-black-4x.svg +346 -0
- Pyjoyment/resources/public/pyjo/logo-white-2x.png +0 -0
- Pyjoyment/resources/public/pyjo/logo-white-2x.svg +346 -0
- Pyjoyment/resources/public/pyjo/logo-white-4x.png +0 -0
- Pyjoyment/resources/public/pyjo/logo-white-4x.svg +346 -0
- Pyjoyment/resources/public/pyjo/logo-white.png +0 -0
- Pyjoyment/resources/public/pyjo/logo-white.svg +346 -0
- Pyjoyment/resources/public/pyjo/logo.png +0 -0
- Pyjoyment/resources/public/pyjo/logo.svg +346 -0
- Pyjoyment/resources/public/pyjo/notfound.png +0 -0
- Pyjoyment/resources/public/pyjo/notfound.svg +3 -0
- Pyjoyment/resources/public/pyjo/nounicat.png +0 -0
- Pyjoyment/resources/public/pyjo/nounicat.svg +5 -0
- Pyjoyment/resources/public/pyjo/pinstripe-dark.png +0 -0
- Pyjoyment/resources/public/pyjo/pinstripe-dark.svg +4 -0
- Pyjoyment/resources/public/pyjo/pinstripe-light.png +0 -0
- Pyjoyment/resources/public/pyjo/pinstripe-light.svg +4 -0
- Pyjoyment/resources/public/pyjo/pyjo.css +204 -0
- Pyjoyment/resources/templates/pyjo/debug.html.ep +346 -0
- Pyjoyment/resources/templates/pyjo/exception.html.ep +32 -0
- Pyjoyment/resources/templates/pyjo/not_found.html.ep +45 -0
- pyjoyment-0.1.0.dist-info/METADATA +376 -0
- pyjoyment-0.1.0.dist-info/RECORD +200 -0
- pyjoyment-0.1.0.dist-info/WHEEL +4 -0
- pyjoyment-0.1.0.dist-info/entry_points.txt +8 -0
- pyjoyment-0.1.0.dist-info/licenses/LICENSE +201 -0
Pyjo/YAML.py
ADDED
|
@@ -0,0 +1,1167 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pyjo.YAML - Minimal YAML loader and dumper
|
|
3
|
+
==========================================
|
|
4
|
+
::
|
|
5
|
+
|
|
6
|
+
from Pyjo.YAML import dump, dump_all, load, load_all
|
|
7
|
+
|
|
8
|
+
config = load(\"\"\"\\
|
|
9
|
+
foo: bar
|
|
10
|
+
list:
|
|
11
|
+
- one
|
|
12
|
+
- two
|
|
13
|
+
\"\"\")
|
|
14
|
+
|
|
15
|
+
documents = load_all('--- first\\n--- second\\n')
|
|
16
|
+
|
|
17
|
+
string = dump({'foo': 'bar', 'list': ['one', 'two']})
|
|
18
|
+
|
|
19
|
+
:mod:`Pyjo.YAML` loads and dumps the YAML that configuration files and
|
|
20
|
+
workflows use, a subset of `YAML 1.2 <https://yaml.org>`_, which
|
|
21
|
+
:mod:`Pyjoyment.Plugin.YAMLConfig` reads, as Python has no YAML in its
|
|
22
|
+
standard library:
|
|
23
|
+
|
|
24
|
+
* mappings and sequences in the block style, nested by their indentation, and
|
|
25
|
+
a mapping or a sequence that starts in an item of a sequence
|
|
26
|
+
(``- name: value``, ``- - item``);
|
|
27
|
+
* flow sequences and mappings, ``[a, b]`` and ``{a: 1}``, nested, and over
|
|
28
|
+
several lines;
|
|
29
|
+
* scalars, plain, in single quotes (with ``''`` for a quote) and in double
|
|
30
|
+
quotes (with the escapes ``\\n``, ``\\t``, ``\\xHH``, ``\\uHHHH`` and the
|
|
31
|
+
others of YAML),
|
|
32
|
+
over several lines too, and the literal ``|`` and folded ``>`` block
|
|
33
|
+
scalars, with their empty lines, and the indicators ``-`` and ``+`` that
|
|
34
|
+
strip and keep the final line breaks;
|
|
35
|
+
* comments, documents that ``---`` starts and ``...`` ends, a scalar after
|
|
36
|
+
``---`` and the directive ``%YAML``.
|
|
37
|
+
|
|
38
|
+
A plain scalar has the type of the core schema of YAML 1.2: ``null`` and ``~``
|
|
39
|
+
are ``None``, ``true`` and ``false`` are booleans, and numbers, with ``0o`` and
|
|
40
|
+
``0x`` too, ``.inf`` and ``.nan``, are :class:`int` and :class:`float`, as
|
|
41
|
+
``404:`` is a key of :class:`int`. The words and the forms of YAML 1.1, as
|
|
42
|
+
``yes``, ``off``, ``0755`` for an octal number and an implicit date, are not of
|
|
43
|
+
the core schema, and stay strings or decimal numbers. A quoted scalar and a
|
|
44
|
+
block scalar are strings. ::
|
|
45
|
+
|
|
46
|
+
# {'foo': [1, True, 'yes', '2']}
|
|
47
|
+
load("foo:\\n - 1\\n - true\\n - yes\\n - '2'\\n")
|
|
48
|
+
|
|
49
|
+
A standard tag gives a scalar its type: ``!!str``, ``!!int``, ``!!float``,
|
|
50
|
+
``!!bool`` and ``!!null`` of YAML 1.2, and ``!!binary``, Base64 for
|
|
51
|
+
:class:`bytes`, and ``!!timestamp``, ISO 8601 for :class:`datetime.date` and
|
|
52
|
+
:class:`datetime.datetime`, of YAML 1.1. ::
|
|
53
|
+
|
|
54
|
+
# {'port': '8080', 'key': b'hello', 'day': datetime.date(2001, 12, 14)}
|
|
55
|
+
load('port: !!str 8080\\nkey: !!binary aGVsbG8=\\nday: !!timestamp 2001-12-14\\n')
|
|
56
|
+
|
|
57
|
+
An anchor ``&name`` names a node, a value or a key, in a block or a flow
|
|
58
|
+
collection, and an alias ``*name`` after it in the same document is the same
|
|
59
|
+
object; an anchor defined again names the node of the aliases after it. The
|
|
60
|
+
merge key ``<<`` of YAML 1.1 puts the keys of the mapping that it names, or of
|
|
61
|
+
a sequence of mappings, the earlier ones first, into the mapping, under the
|
|
62
|
+
keys of the mapping itself. ::
|
|
63
|
+
|
|
64
|
+
# {'base': {'a': 1, 'b': 1}, 'copy': {'a': 1, 'b': 2}}
|
|
65
|
+
load('base: &base\\n a: 1\\n b: 1\\ncopy:\\n <<: *base\\n b: 2\\n')
|
|
66
|
+
|
|
67
|
+
Other tags, complex keys and the indicator of the indentation of a block
|
|
68
|
+
scalar are not supported, and raise an error, as do lines that the subset
|
|
69
|
+
does not know.
|
|
70
|
+
|
|
71
|
+
:func:`dump` writes what the loader reads, in the block style of
|
|
72
|
+
configuration files: a :class:`dict` is a mapping, a :class:`list` and a
|
|
73
|
+
:class:`tuple` are a sequence, ``{}`` and ``[]`` when they are empty, and a
|
|
74
|
+
string is plain where it reads back as the same string, quoted otherwise, and
|
|
75
|
+
a literal block scalar when it has several lines. :class:`bytes` and dates
|
|
76
|
+
have the tags ``!!binary`` and ``!!timestamp``, and another object is what
|
|
77
|
+
:mod:`Pyjo.JSON` encodes, the value of its method ``__json__`` or its string.
|
|
78
|
+
A :class:`dict` or a :class:`list` that is there more than once has an anchor
|
|
79
|
+
where it is first and an alias after it, which the loader reads back as the
|
|
80
|
+
same object, and one inside itself raises an error, as the loader cannot read
|
|
81
|
+
it back; a :class:`tuple` has no identity of its own, as the compiler can
|
|
82
|
+
share one that is a constant, and it is written again. ::
|
|
83
|
+
|
|
84
|
+
# 'name: Pyjoyment\\nlist:\\n - one\\n - 2\\nrun: |\\n make\\n'
|
|
85
|
+
dump({'name': 'Pyjoyment', 'list': ['one', 2], 'run': 'make\\n'})
|
|
86
|
+
|
|
87
|
+
See also
|
|
88
|
+
--------
|
|
89
|
+
|
|
90
|
+
:mod:`Pyjoyment.Plugin.YAMLConfig`, https://mojolicious.org.
|
|
91
|
+
|
|
92
|
+
Inspired by `CPAN::Meta::YAML <https://metacpan.org/pod/CPAN::Meta::YAML>`_
|
|
93
|
+
of Adam Kennedy and David Golden, which Mojolicious uses, whose regular
|
|
94
|
+
expressions of scalars and keys it borrows, with thanks.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
import Pyjo.Exception
|
|
98
|
+
|
|
99
|
+
import base64
|
|
100
|
+
import binascii
|
|
101
|
+
import builtins
|
|
102
|
+
import collections
|
|
103
|
+
import datetime
|
|
104
|
+
import math
|
|
105
|
+
import re
|
|
106
|
+
|
|
107
|
+
from collections.abc import Callable, Iterable
|
|
108
|
+
from typing import Any, Literal, cast
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# The regular expressions of CPAN::Meta::YAML
|
|
112
|
+
_DOUBLE_QUOTED = r'"([^\\"]*(?:\\.[^\\"]*)*)"'
|
|
113
|
+
_SINGLE_QUOTED = r"'([^']*(?:''[^']*)*)'"
|
|
114
|
+
_UNQUOTED_KEY = r'([^:]+(?::+\S(?:[^:]*|.*?(?=:)))*)(?=\s*:(?:\s+|$))'
|
|
115
|
+
_TRAILING_COMMENT = r'(?:\s+#.*)?'
|
|
116
|
+
_KEY_VALUE_SEPARATOR = r'\s*:(?:\s+(?:#.*)?|$)'
|
|
117
|
+
|
|
118
|
+
_RE_DOUBLE = re.compile(rf'^{_DOUBLE_QUOTED}{_TRAILING_COMMENT}\Z')
|
|
119
|
+
_RE_SINGLE = re.compile(rf'^{_SINGLE_QUOTED}{_TRAILING_COMMENT}\Z')
|
|
120
|
+
_RE_KEY_DOUBLE = re.compile(rf'^\s*{_DOUBLE_QUOTED}{_KEY_VALUE_SEPARATOR}')
|
|
121
|
+
_RE_KEY_SINGLE = re.compile(rf'^\s*{_SINGLE_QUOTED}{_KEY_VALUE_SEPARATOR}')
|
|
122
|
+
_RE_KEY_UNQUOTED = re.compile(rf'^\s*{_UNQUOTED_KEY}{_KEY_VALUE_SEPARATOR}')
|
|
123
|
+
_RE_BLANK = re.compile(r'^\s*(?:#.*)?\Z')
|
|
124
|
+
|
|
125
|
+
# Not in CPAN::Meta::YAML: the name of an anchor or an alias, an anchor of a key and an alias as a key
|
|
126
|
+
_ANCHOR = r'[^\s,\[\]{}]+'
|
|
127
|
+
_RE_KEY_ALIAS = re.compile(rf'^\s*\*({_ANCHOR}){_KEY_VALUE_SEPARATOR}')
|
|
128
|
+
_RE_KEY_ANCHOR = re.compile(rf'^(\s*)&({_ANCHOR})\s+')
|
|
129
|
+
|
|
130
|
+
_ESCAPES = {
|
|
131
|
+
'0': '\x00',
|
|
132
|
+
'L': '\u2028',
|
|
133
|
+
'N': '\x85',
|
|
134
|
+
'P': '\u2029',
|
|
135
|
+
'a': '\x07',
|
|
136
|
+
'b': '\x08',
|
|
137
|
+
'e': '\x1b',
|
|
138
|
+
'f': '\x0c',
|
|
139
|
+
'n': '\x0a',
|
|
140
|
+
'r': '\x0d',
|
|
141
|
+
't': '\x09',
|
|
142
|
+
'v': '\x0b',
|
|
143
|
+
'z': '\x00',
|
|
144
|
+
'\\': '\\',
|
|
145
|
+
'_': '\xa0',
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def dump(
|
|
150
|
+
value: Any,
|
|
151
|
+
*,
|
|
152
|
+
indent: int = 2,
|
|
153
|
+
indent_sequences: bool = True,
|
|
154
|
+
quote: Literal["'", '"'] | None = None,
|
|
155
|
+
quote_keys: Literal["'", '"'] | None = None,
|
|
156
|
+
multiline: bool = True,
|
|
157
|
+
explicit_start: bool = False,
|
|
158
|
+
version: Literal['1.1', '1.2'] = '1.1',
|
|
159
|
+
anchors: bool = True,
|
|
160
|
+
sort_keys: bool = False,
|
|
161
|
+
) -> str:
|
|
162
|
+
"""::
|
|
163
|
+
|
|
164
|
+
string = dump(value)
|
|
165
|
+
string = dump(value, sort_keys=True)
|
|
166
|
+
string = dump(value, indent=4, indent_sequences=False, quote='"')
|
|
167
|
+
|
|
168
|
+
Dump a value to a YAML string of one document, with the options:
|
|
169
|
+
|
|
170
|
+
* ``indent``, the number of spaces of a level of the indentation, ``2``
|
|
171
|
+
by default;
|
|
172
|
+
* ``indent_sequences``, whether a sequence below a key is indented,
|
|
173
|
+
``True`` by default, or has its items at the column of the key;
|
|
174
|
+
* ``quote``, the quotes of every string value, ``"'"`` or ``'"'``, or
|
|
175
|
+
``None`` by default, for quotes only where a string needs them, single
|
|
176
|
+
ones unless it needs the escapes of the double ones, which a string
|
|
177
|
+
gets with any of them;
|
|
178
|
+
* ``quote_keys``, the same for the keys, which are quoted only where they
|
|
179
|
+
need it by default;
|
|
180
|
+
* ``multiline``, whether a string of several lines is a literal block
|
|
181
|
+
scalar, ``True`` by default, or a quoted scalar; a string whose lines a
|
|
182
|
+
block scalar cannot keep, as one with a first line that starts with a
|
|
183
|
+
space, is quoted anyway;
|
|
184
|
+
* ``explicit_start``, whether the document starts with ``---``,
|
|
185
|
+
``False`` by default; a scalar document always does;
|
|
186
|
+
* ``version``, the YAML whose reader gets the strings back: ``'1.1'`` by
|
|
187
|
+
default quotes also the strings that YAML 1.1 reads as other types,
|
|
188
|
+
as ``yes``, ``off``, ``1_000``, ``12:30`` and ``2001-12-14``, which
|
|
189
|
+
PyYAML reads so, and ``'1.2'`` only those of the core schema of YAML
|
|
190
|
+
1.2, as ``true``, ``null`` and ``1.5``;
|
|
191
|
+
* ``anchors``, whether a :class:`dict` or a :class:`list` that is there
|
|
192
|
+
more than once has an anchor where it is first and an alias after it,
|
|
193
|
+
``True`` by default, or is written again; an anchor has the name of the
|
|
194
|
+
key of the collection, or of the sequence of the item, with the
|
|
195
|
+
characters that are not ``A-Z``, ``a-z``, ``0-9``, ``_`` and ``-`` as
|
|
196
|
+
``_``, ``id`` without a key, and a number from ``2`` for another
|
|
197
|
+
anchor of the same name.
|
|
198
|
+
* ``sort_keys``, whether the keys of a mapping are sorted, ``False`` by
|
|
199
|
+
default, for the order of the :class:`dict`. ::
|
|
200
|
+
|
|
201
|
+
# 'foo:\\n- "bar"\\n'
|
|
202
|
+
dump({'foo': ['bar']}, indent_sequences=False, quote='"')
|
|
203
|
+
"""
|
|
204
|
+
return dump_all(
|
|
205
|
+
[value],
|
|
206
|
+
indent=indent,
|
|
207
|
+
indent_sequences=indent_sequences,
|
|
208
|
+
quote=quote,
|
|
209
|
+
quote_keys=quote_keys,
|
|
210
|
+
multiline=multiline,
|
|
211
|
+
explicit_start=explicit_start,
|
|
212
|
+
version=version,
|
|
213
|
+
anchors=anchors,
|
|
214
|
+
sort_keys=sort_keys,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def dump_all(
|
|
219
|
+
values: Iterable[Any],
|
|
220
|
+
*,
|
|
221
|
+
indent: int = 2,
|
|
222
|
+
indent_sequences: bool = True,
|
|
223
|
+
quote: Literal["'", '"'] | None = None,
|
|
224
|
+
quote_keys: Literal["'", '"'] | None = None,
|
|
225
|
+
multiline: bool = True,
|
|
226
|
+
explicit_start: bool = False,
|
|
227
|
+
version: Literal['1.1', '1.2'] = '1.1',
|
|
228
|
+
anchors: bool = True,
|
|
229
|
+
sort_keys: bool = False,
|
|
230
|
+
) -> str:
|
|
231
|
+
"""::
|
|
232
|
+
|
|
233
|
+
string = dump_all(values)
|
|
234
|
+
|
|
235
|
+
Dump values to a YAML string of documents, one for each value, with the
|
|
236
|
+
options of :func:`dump`, where the documents after the first one start
|
|
237
|
+
with ``---``. ::
|
|
238
|
+
|
|
239
|
+
# '--- first\\n--- second\\n'
|
|
240
|
+
dump_all(['first', 'second'])
|
|
241
|
+
"""
|
|
242
|
+
dumper = _Dumper(indent, indent_sequences, quote, quote_keys, multiline, version, anchors, sort_keys)
|
|
243
|
+
lines: list[str] = []
|
|
244
|
+
for i, value in enumerate(values):
|
|
245
|
+
lines += dumper.document(value, explicit_start or i > 0)
|
|
246
|
+
return ''.join(f'{line}\n' for line in lines)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def load(string: str) -> Any:
|
|
250
|
+
"""::
|
|
251
|
+
|
|
252
|
+
value = load(string)
|
|
253
|
+
|
|
254
|
+
Load a YAML string and return its document, or its last one when there
|
|
255
|
+
are several, as the ``Load`` of ``YAML`` of Perl does, or ``None`` for an
|
|
256
|
+
empty string. ::
|
|
257
|
+
|
|
258
|
+
# {'foo': 'bar'}
|
|
259
|
+
load('foo: bar')
|
|
260
|
+
"""
|
|
261
|
+
documents = load_all(string)
|
|
262
|
+
return documents[-1] if documents else None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def load_all(string: str) -> list[Any]:
|
|
266
|
+
"""::
|
|
267
|
+
|
|
268
|
+
values = load_all(string)
|
|
269
|
+
|
|
270
|
+
Load a YAML string and return a list of all its documents. ::
|
|
271
|
+
|
|
272
|
+
# ['first', 'second']
|
|
273
|
+
load_all('--- first\\n--- second\\n')
|
|
274
|
+
"""
|
|
275
|
+
return _Loader(string).documents()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _error(message: str) -> Pyjo.Exception.object:
|
|
279
|
+
return Pyjo.Exception.new(f'YAML {message}')
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _indent(line: str) -> int:
|
|
283
|
+
return len(line) - len(line.lstrip())
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _unquote_double(string: str) -> str:
|
|
287
|
+
def unescape(m: re.Match[str]) -> str:
|
|
288
|
+
if (code := m[1] or m[2] or m[3]) is not None:
|
|
289
|
+
return chr(int(code, 16))
|
|
290
|
+
return _ESCAPES.get(m[4], m[4])
|
|
291
|
+
|
|
292
|
+
return re.sub(
|
|
293
|
+
r'\\(?:x([0-9a-fA-F]{2})|u([0-9a-fA-F]{4})|U(0010[0-9a-fA-F]{4}|000[0-9a-fA-F]{5})|(["\\/ \t0LNP_abefnrtvz]))',
|
|
294
|
+
unescape,
|
|
295
|
+
string,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# Not in CPAN::Meta::YAML: the type of a plain scalar of the core schema of YAML 1.2, where CPAN::Meta::YAML keeps a
|
|
300
|
+
# string, as Perl takes "8080" for 8080, and Python does not
|
|
301
|
+
def _resolve(string: str) -> Any:
|
|
302
|
+
if re.fullmatch(r'~|null|Null|NULL', string):
|
|
303
|
+
return None
|
|
304
|
+
if re.fullmatch(r'true|True|TRUE', string):
|
|
305
|
+
return True
|
|
306
|
+
if re.fullmatch(r'false|False|FALSE', string):
|
|
307
|
+
return False
|
|
308
|
+
if re.fullmatch(r'[-+]?[0-9]+', string):
|
|
309
|
+
return int(string)
|
|
310
|
+
if re.fullmatch(r'0o[0-7]+', string):
|
|
311
|
+
return int(string[2:], 8)
|
|
312
|
+
if re.fullmatch(r'0x[0-9a-fA-F]+', string):
|
|
313
|
+
return int(string[2:], 16)
|
|
314
|
+
if re.fullmatch(r'[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)(?:[eE][-+]?[0-9]+)?', string):
|
|
315
|
+
return float(string)
|
|
316
|
+
if m := re.fullmatch(r'([-+]?)\.(?:inf|Inf|INF)', string):
|
|
317
|
+
return float(f'{m[1]}inf')
|
|
318
|
+
if re.fullmatch(r'\.(?:nan|NaN|NAN)', string):
|
|
319
|
+
return float('nan')
|
|
320
|
+
return string
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# The standard tags of the scalars, of the core schema of YAML 1.2 and, for the types of Python they have, of YAML 1.1
|
|
324
|
+
def _tag_binary(value: str) -> bytes:
|
|
325
|
+
try:
|
|
326
|
+
return base64.b64decode(re.sub(r'\s+', '', value), validate=True)
|
|
327
|
+
except binascii.Error as e:
|
|
328
|
+
raise ValueError(str(e)) from None
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _tag_bool(value: str) -> bool:
|
|
332
|
+
if (resolved := _resolve(value)) is True or resolved is False:
|
|
333
|
+
return resolved
|
|
334
|
+
raise ValueError(value)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _tag_float(value: str) -> float:
|
|
338
|
+
if isinstance(resolved := _resolve(value), (int, float)) and not isinstance(resolved, bool):
|
|
339
|
+
return float(resolved)
|
|
340
|
+
raise ValueError(value)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _tag_int(value: str) -> int:
|
|
344
|
+
if isinstance(resolved := _resolve(value), int) and not isinstance(resolved, bool):
|
|
345
|
+
return resolved
|
|
346
|
+
raise ValueError(value)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _tag_null(value: str) -> None:
|
|
350
|
+
if value not in ('', '~') and _resolve(value) is not None:
|
|
351
|
+
raise ValueError(value)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _tag_timestamp(value: str) -> datetime.date | datetime.datetime:
|
|
355
|
+
if re.fullmatch(r'\d{4}-\d\d-\d\d', value):
|
|
356
|
+
return datetime.date.fromisoformat(value)
|
|
357
|
+
return datetime.datetime.fromisoformat(re.sub(r'(?<=\d)[tT](?=\d)', 'T', value))
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
_TAGS: dict[str, Callable[[str], Any]] = {
|
|
361
|
+
'binary': _tag_binary,
|
|
362
|
+
'bool': _tag_bool,
|
|
363
|
+
'float': _tag_float,
|
|
364
|
+
'int': _tag_int,
|
|
365
|
+
'null': _tag_null,
|
|
366
|
+
'str': str,
|
|
367
|
+
'timestamp': _tag_timestamp,
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _unquote_single(string: str) -> str:
|
|
372
|
+
return string.replace("''", "'")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class _Loader:
|
|
376
|
+
def __init__(self, string: str) -> None:
|
|
377
|
+
self.anchors: dict[str, Any] = {}
|
|
378
|
+
# A deque, whose popleft takes the first line as shift of Perl does, where pop(0) of a list moves all the others
|
|
379
|
+
self.lines = collections.deque(re.split(r'\r\r?\n|\r|\n', string.removeprefix('\ufeff')))
|
|
380
|
+
|
|
381
|
+
# The line break at the end ends the last line, it does not start an empty one
|
|
382
|
+
if self.lines[-1] == '':
|
|
383
|
+
self.lines.pop()
|
|
384
|
+
|
|
385
|
+
def alias(self, name: str) -> Any:
|
|
386
|
+
if name not in self.anchors:
|
|
387
|
+
raise _error(f"found an unknown alias '*{name}'")
|
|
388
|
+
return self.anchors[name]
|
|
389
|
+
|
|
390
|
+
def block(self, string: str, indent: int | None) -> str:
|
|
391
|
+
if not (m := re.search(r'^([|>])([+-]?)\s*(?:#.*)?\Z', string)):
|
|
392
|
+
raise _error(f"does not support a feature in line '{string}'")
|
|
393
|
+
|
|
394
|
+
# The lines more indented than the key, with the empty ones among them
|
|
395
|
+
lines = self.lines
|
|
396
|
+
block: list[str] = []
|
|
397
|
+
depth: int | None = None
|
|
398
|
+
while lines:
|
|
399
|
+
if not lines[0].strip():
|
|
400
|
+
block.append('')
|
|
401
|
+
lines.popleft()
|
|
402
|
+
continue
|
|
403
|
+
if depth is None:
|
|
404
|
+
if indent is not None and _indent(lines[0]) <= indent:
|
|
405
|
+
break
|
|
406
|
+
depth = _indent(lines[0])
|
|
407
|
+
if _indent(lines[0]) < depth:
|
|
408
|
+
break
|
|
409
|
+
block.append(lines.popleft()[depth:])
|
|
410
|
+
|
|
411
|
+
# The empty lines at the end, which the chomping indicator keeps, strips or clips to one line break
|
|
412
|
+
trailing = len(block) - len(content := _rstrip_empty(block))
|
|
413
|
+
text = '\n'.join(content) if m[1] == '|' else _fold(content)
|
|
414
|
+
if m[2] == '-' or not content:
|
|
415
|
+
return text
|
|
416
|
+
return text + '\n' * (1 + trailing if m[2] == '+' else 1)
|
|
417
|
+
|
|
418
|
+
def documents(self) -> list[Any]:
|
|
419
|
+
documents: list[Any] = []
|
|
420
|
+
explicit = False
|
|
421
|
+
lines = self.lines
|
|
422
|
+
|
|
423
|
+
# The directive of the version
|
|
424
|
+
if self.skip() and re.search(r'^%YAML[: ][\d.]+', lines[0]):
|
|
425
|
+
lines.popleft()
|
|
426
|
+
|
|
427
|
+
while self.skip():
|
|
428
|
+
# The anchors of a document are its own
|
|
429
|
+
self.anchors = {}
|
|
430
|
+
|
|
431
|
+
# The start of a document, with a scalar after it
|
|
432
|
+
if m := re.search(r'^---\s*(?:(.+?)\s*)?\Z', lines[0]):
|
|
433
|
+
lines.popleft()
|
|
434
|
+
if m[1] is not None and not re.search(r'^(?:#.+|%YAML[: ][\d.]+)\Z', m[1]):
|
|
435
|
+
documents.append(self.scalar(m[1], None))
|
|
436
|
+
continue
|
|
437
|
+
explicit = True
|
|
438
|
+
|
|
439
|
+
# An empty document
|
|
440
|
+
if not self.skip() or re.search(r'^(?:---|\.\.\.)', lines[0]):
|
|
441
|
+
documents.append(None)
|
|
442
|
+
while lines and not lines[0].startswith('---'):
|
|
443
|
+
lines.popleft()
|
|
444
|
+
explicit = False
|
|
445
|
+
|
|
446
|
+
# Only the first document can do without its start
|
|
447
|
+
elif not explicit and documents:
|
|
448
|
+
raise _error(f"failed to classify the line '{lines[0]}'")
|
|
449
|
+
|
|
450
|
+
# A sequence
|
|
451
|
+
elif re.search(r'^\s*-(?:\s|$|-+$)', lines[0]):
|
|
452
|
+
documents.append(self.sequence(0))
|
|
453
|
+
|
|
454
|
+
# A flow collection
|
|
455
|
+
elif re.search(r'^\s*[\[{]', lines[0]):
|
|
456
|
+
documents.append(self.scalar(lines.popleft().strip(), None))
|
|
457
|
+
|
|
458
|
+
# A mapping
|
|
459
|
+
else:
|
|
460
|
+
documents.append(self.mapping(_indent(lines[0])))
|
|
461
|
+
|
|
462
|
+
return documents
|
|
463
|
+
|
|
464
|
+
def end_of_document(self) -> bool:
|
|
465
|
+
lines = self.lines
|
|
466
|
+
if not self.skip():
|
|
467
|
+
return True
|
|
468
|
+
if not re.search(r'^(?:---|\.\.\.)', lines[0]):
|
|
469
|
+
return False
|
|
470
|
+
while lines and not lines[0].startswith('---'):
|
|
471
|
+
lines.popleft()
|
|
472
|
+
return True
|
|
473
|
+
|
|
474
|
+
def flow(self, string: str) -> Any:
|
|
475
|
+
# The lines of the collection, until its brackets are closed
|
|
476
|
+
text = string
|
|
477
|
+
while (end := _flow_end(text)) is None:
|
|
478
|
+
if not self.lines:
|
|
479
|
+
raise _error(f"failed to find the end of the flow collection '{string}'")
|
|
480
|
+
if not _RE_BLANK.search(line := self.lines.popleft()):
|
|
481
|
+
text += ' ' + line.strip()
|
|
482
|
+
if (rest := text[end:].strip()) and not rest.startswith('#'):
|
|
483
|
+
raise _error(f"found illegal characters after the flow collection '{text}'")
|
|
484
|
+
return _Flow(text[:end], self).value()
|
|
485
|
+
|
|
486
|
+
def mapping(self, indent: int, outer: int | None = None) -> dict[Any, Any]:
|
|
487
|
+
mapping: dict[Any, Any] = {}
|
|
488
|
+
merges: list[Any] = []
|
|
489
|
+
lines = self.lines
|
|
490
|
+
while not self.end_of_document():
|
|
491
|
+
if (depth := _indent(lines[0])) < indent:
|
|
492
|
+
break
|
|
493
|
+
if depth > indent:
|
|
494
|
+
raise _error(f"found bad indenting in line '{lines[0]}'")
|
|
495
|
+
|
|
496
|
+
# Not in CPAN::Meta::YAML: the anchor of the key
|
|
497
|
+
line = lines[0]
|
|
498
|
+
anchor = None
|
|
499
|
+
if m := _RE_KEY_ANCHOR.search(line):
|
|
500
|
+
anchor = m[2]
|
|
501
|
+
line = m[1] + line[m.end() :]
|
|
502
|
+
|
|
503
|
+
# The key, quoted or not, or an alias, and the rest of the line
|
|
504
|
+
if m := _RE_KEY_ALIAS.search(line):
|
|
505
|
+
quote = '*'
|
|
506
|
+
key = self.alias(m[1])
|
|
507
|
+
elif re.search(r'^\s*[!&*]', line):
|
|
508
|
+
raise _error(f"does not support a feature in line '{line}'")
|
|
509
|
+
elif m := _key(line):
|
|
510
|
+
quote = line.lstrip()[:1]
|
|
511
|
+
if quote == "'":
|
|
512
|
+
key = _unquote_single(m[1])
|
|
513
|
+
elif quote == '"':
|
|
514
|
+
key = _unquote_double(m[1])
|
|
515
|
+
else:
|
|
516
|
+
key = _resolve(m[1].rstrip())
|
|
517
|
+
elif re.search(r'^\s*\?', line):
|
|
518
|
+
raise _error(f"does not support a feature in line '{line}'")
|
|
519
|
+
else:
|
|
520
|
+
raise _error(f"failed to classify line '{line}'")
|
|
521
|
+
rest = line[m.end() :]
|
|
522
|
+
lines.popleft()
|
|
523
|
+
try:
|
|
524
|
+
hash(key)
|
|
525
|
+
except TypeError:
|
|
526
|
+
raise _error(f"found a key that is not hashable in line '{line}'") from None
|
|
527
|
+
if anchor is not None:
|
|
528
|
+
self.anchors[anchor] = key
|
|
529
|
+
|
|
530
|
+
# A value on the line, or below it, or none
|
|
531
|
+
value = self.scalar(rest, indent) if rest else self.value(indent)
|
|
532
|
+
if key == '<<' and quote not in ("'", '"', '*'):
|
|
533
|
+
merges.append(value)
|
|
534
|
+
else:
|
|
535
|
+
mapping[key] = value
|
|
536
|
+
|
|
537
|
+
return _merge(mapping, merges)
|
|
538
|
+
|
|
539
|
+
def plain(self, string: str, indent: int | None) -> str:
|
|
540
|
+
if re.search(r'^(?:-(?:\s|$)|[@%`])', string) or re.search(r':(?:\s|$)', string):
|
|
541
|
+
raise _error(f"found illegal characters in plain scalar: '{string}'")
|
|
542
|
+
plain = re.sub(r'\s+#.*\Z', '', string)
|
|
543
|
+
if plain != string or indent is None:
|
|
544
|
+
return plain
|
|
545
|
+
|
|
546
|
+
# The lines more indented than the key, folded, which a comment ends
|
|
547
|
+
lines = self.lines
|
|
548
|
+
empty = 0
|
|
549
|
+
while lines and (indent < _indent(lines[0]) or not lines[0].strip()):
|
|
550
|
+
line = lines[0].strip()
|
|
551
|
+
if line.startswith('#'):
|
|
552
|
+
break
|
|
553
|
+
lines.popleft()
|
|
554
|
+
if not line:
|
|
555
|
+
empty += 1
|
|
556
|
+
continue
|
|
557
|
+
if re.search(r':(?:\s|$)', line):
|
|
558
|
+
raise _error(f"found illegal characters in plain scalar: '{line}'")
|
|
559
|
+
continued = re.sub(r'\s+#.*\Z', '', line)
|
|
560
|
+
plain += ('\n' * empty if empty else ' ') + continued
|
|
561
|
+
empty = 0
|
|
562
|
+
if continued != line:
|
|
563
|
+
break
|
|
564
|
+
return plain
|
|
565
|
+
|
|
566
|
+
def quoted(self, string: str) -> str:
|
|
567
|
+
double = string.startswith('"')
|
|
568
|
+
pattern = _RE_DOUBLE if double else _RE_SINGLE
|
|
569
|
+
|
|
570
|
+
# The lines of the scalar, until its quote is closed, folded
|
|
571
|
+
text = string
|
|
572
|
+
empty = 0
|
|
573
|
+
while not (m := pattern.search(text)):
|
|
574
|
+
if not self.lines:
|
|
575
|
+
raise _error(f"failed to find the end of the quoted scalar '{string}'")
|
|
576
|
+
line = self.lines.popleft().strip()
|
|
577
|
+
if not line:
|
|
578
|
+
empty += 1
|
|
579
|
+
continue
|
|
580
|
+
text = text.rstrip(' \t')
|
|
581
|
+
if double and re.search(r'(?<!\\)(?:\\\\)*\\\Z', text) and not empty:
|
|
582
|
+
text = text[:-1] + line
|
|
583
|
+
else:
|
|
584
|
+
text += ('\n' * empty if empty else ' ') + line
|
|
585
|
+
empty = 0
|
|
586
|
+
return _unquote_double(m[1]) if double else _unquote_single(m[1])
|
|
587
|
+
|
|
588
|
+
def scalar(self, string: str, indent: int | None, item: bool = False) -> Any:
|
|
589
|
+
string = string.rstrip()
|
|
590
|
+
|
|
591
|
+
# Not in CPAN::Meta::YAML: an alias, the node of its anchor
|
|
592
|
+
if string.startswith('*'):
|
|
593
|
+
if not (m := re.search(rf'^\*({_ANCHOR})(?:\s+#.*)?\Z', string)):
|
|
594
|
+
raise _error(f"found illegal characters after the alias '{string}'")
|
|
595
|
+
return self.alias(m[1])
|
|
596
|
+
|
|
597
|
+
# Not in CPAN::Meta::YAML: the properties of a node, an anchor and a standard tag, in either order
|
|
598
|
+
anchor = tag = None
|
|
599
|
+
while m := re.search(rf'^(?:&({_ANCHOR})|!!(\w+))(?:\s+|\Z)', string):
|
|
600
|
+
if m[1] is not None and anchor is None:
|
|
601
|
+
anchor = m[1]
|
|
602
|
+
elif m[2] is not None and tag is None:
|
|
603
|
+
tag = m[2]
|
|
604
|
+
else:
|
|
605
|
+
raise _error(f"found a property twice in line '{string}'")
|
|
606
|
+
string = string[m.end() :]
|
|
607
|
+
|
|
608
|
+
# A node below the anchor
|
|
609
|
+
if anchor is not None and tag is None and (not string or string.startswith('#')):
|
|
610
|
+
value = self.value(-1 if indent is None else indent, item)
|
|
611
|
+
|
|
612
|
+
# The tag, which gives a scalar its type
|
|
613
|
+
elif tag is not None:
|
|
614
|
+
if (convert := _TAGS.get(tag)) is None or string.startswith(('[', '{')):
|
|
615
|
+
raise _error(f"does not support a feature in line '!!{tag} {string}'")
|
|
616
|
+
text = self.text(string, indent, False)
|
|
617
|
+
try:
|
|
618
|
+
value = convert(text)
|
|
619
|
+
except ValueError:
|
|
620
|
+
raise _error(f"found an invalid value of the tag !!{tag}: '{text}'") from None
|
|
621
|
+
|
|
622
|
+
else:
|
|
623
|
+
value = self.text(string, indent, True)
|
|
624
|
+
|
|
625
|
+
# Unlike PyYAML: an anchor can be defined again, as in YAML 1.2, and an alias is the node of the last one
|
|
626
|
+
if anchor is not None:
|
|
627
|
+
self.anchors[anchor] = value
|
|
628
|
+
return value
|
|
629
|
+
|
|
630
|
+
def sequence(self, indent: int, outer: int | None = None) -> list[Any]:
|
|
631
|
+
sequence: list[Any] = []
|
|
632
|
+
lines = self.lines
|
|
633
|
+
while not self.end_of_document():
|
|
634
|
+
if (depth := _indent(lines[0])) < indent:
|
|
635
|
+
break
|
|
636
|
+
if depth > indent:
|
|
637
|
+
raise _error(f"found bad indenting in line '{lines[0]}'")
|
|
638
|
+
|
|
639
|
+
# Not in CPAN::Meta::YAML: a sequence that starts on the line of the item
|
|
640
|
+
if m := re.search(r'^(\s*-\s+)-(?:\s|\Z)', lines[0]):
|
|
641
|
+
lines[0] = lines[0].replace('-', ' ', 1)
|
|
642
|
+
sequence.append(self.sequence(len(m[1])))
|
|
643
|
+
|
|
644
|
+
# A mapping that starts on the line of the item
|
|
645
|
+
elif (m := re.search(r'^(\s*-\s+)(?![\[{!|>])', lines[0])) and _is_key(lines[0][m.end() :]):
|
|
646
|
+
lines[0] = lines[0].replace('-', ' ', 1)
|
|
647
|
+
sequence.append(self.mapping(len(m[1]), indent))
|
|
648
|
+
|
|
649
|
+
# An item whose value is below it, or none
|
|
650
|
+
elif re.search(r'^\s*-\s*(?:#.*)?\Z', lines[0]):
|
|
651
|
+
lines.popleft()
|
|
652
|
+
sequence.append(self.value(indent, True))
|
|
653
|
+
|
|
654
|
+
# An item with a value
|
|
655
|
+
elif m := re.search(r'^\s*-\s+(.+?)\s*\Z', lines[0]):
|
|
656
|
+
lines.popleft()
|
|
657
|
+
sequence.append(self.scalar(m[1], indent, True))
|
|
658
|
+
|
|
659
|
+
# The key of a mapping after a sequence at its indentation
|
|
660
|
+
elif outer is not None and indent == outer:
|
|
661
|
+
break
|
|
662
|
+
|
|
663
|
+
else:
|
|
664
|
+
raise _error(f"failed to classify line '{lines[0]}'")
|
|
665
|
+
|
|
666
|
+
return sequence
|
|
667
|
+
|
|
668
|
+
def skip(self) -> bool:
|
|
669
|
+
# The empty lines and the lines of comments, before the next line of the structure
|
|
670
|
+
lines = self.lines
|
|
671
|
+
while lines and _RE_BLANK.search(lines[0]):
|
|
672
|
+
lines.popleft()
|
|
673
|
+
return bool(lines)
|
|
674
|
+
|
|
675
|
+
def text(self, string: str, indent: int | None, resolve: bool) -> Any:
|
|
676
|
+
if string.startswith(('[', '{')):
|
|
677
|
+
return self.flow(string)
|
|
678
|
+
if string.startswith(("'", '"')):
|
|
679
|
+
return self.quoted(string)
|
|
680
|
+
if re.search(r'^[!&*]', string):
|
|
681
|
+
raise _error(f"does not support a feature in line '{string}'")
|
|
682
|
+
if string.startswith(('|', '>')):
|
|
683
|
+
return self.block(string, indent)
|
|
684
|
+
plain = self.plain(string, indent)
|
|
685
|
+
return _resolve(plain) if resolve else plain
|
|
686
|
+
|
|
687
|
+
def value(self, indent: int, item: bool = False) -> Any:
|
|
688
|
+
# A sequence, a mapping or a scalar below its key or its item, or nothing
|
|
689
|
+
lines = self.lines
|
|
690
|
+
if not self.skip() or re.search(r'^(?:---|\.\.\.)', lines[0]):
|
|
691
|
+
return None
|
|
692
|
+
depth = _indent(lines[0])
|
|
693
|
+
if m := re.search(r'^(\s*)-(?:\s|\Z)', lines[0]):
|
|
694
|
+
if item and len(m[1]) == indent:
|
|
695
|
+
return None
|
|
696
|
+
if len(m[1]) < indent or item and len(m[1]) <= indent:
|
|
697
|
+
return None
|
|
698
|
+
return self.sequence(len(m[1]), indent)
|
|
699
|
+
if depth <= indent:
|
|
700
|
+
return None
|
|
701
|
+
if _is_key(lines[0]):
|
|
702
|
+
return self.mapping(depth, indent)
|
|
703
|
+
if re.search(r'^\s*\?', lines[0]):
|
|
704
|
+
raise _error(f"does not support a feature in line '{lines[0]}'")
|
|
705
|
+
return self.scalar(lines.popleft().strip(), indent)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
class _Flow:
|
|
709
|
+
# A flow collection, in one string, with the anchors of its document
|
|
710
|
+
def __init__(self, text: str, loader: _Loader) -> None:
|
|
711
|
+
self.loader = loader
|
|
712
|
+
self.text = text
|
|
713
|
+
self.pos = 0
|
|
714
|
+
|
|
715
|
+
def entry(self, end: str) -> tuple[Any, bool]:
|
|
716
|
+
# A node, or a pair of a key and a value, as a mapping is an entry of a sequence
|
|
717
|
+
if (c := self.peek()) in ('"', "'"):
|
|
718
|
+
key: Any = self.quoted()
|
|
719
|
+
elif c in ('[', '{', '*', '&'):
|
|
720
|
+
return self.node(), False
|
|
721
|
+
else:
|
|
722
|
+
key = self.plain(end)
|
|
723
|
+
if self.peek() == ':':
|
|
724
|
+
self.pos += 1
|
|
725
|
+
value = None if self.peek() in (',', end) else self.node()
|
|
726
|
+
return {key if c in ('"', "'") else _resolve(key): value}, True
|
|
727
|
+
return (key if c in ('"', "'") else _resolve(key)), False
|
|
728
|
+
|
|
729
|
+
def error(self) -> Pyjo.Exception.object:
|
|
730
|
+
return _error(f"failed to parse the flow collection '{self.text}' at offset {self.pos}")
|
|
731
|
+
|
|
732
|
+
def mapping(self) -> dict[Any, Any]:
|
|
733
|
+
mapping: dict[Any, Any] = {}
|
|
734
|
+
merges: list[Any] = []
|
|
735
|
+
self.pos += 1
|
|
736
|
+
while (c := self.peek()) != '}':
|
|
737
|
+
key = self.quoted() if c in ('"', "'") else _resolve(self.plain('}'))
|
|
738
|
+
value = None
|
|
739
|
+
if self.peek() == ':':
|
|
740
|
+
self.pos += 1
|
|
741
|
+
value = None if self.peek() in (',', '}') else self.node()
|
|
742
|
+
if key == '<<' and c not in ('"', "'"):
|
|
743
|
+
merges.append(value)
|
|
744
|
+
else:
|
|
745
|
+
mapping[key] = value
|
|
746
|
+
if self.peek() == ',':
|
|
747
|
+
self.pos += 1
|
|
748
|
+
elif self.peek() != '}':
|
|
749
|
+
raise self.error()
|
|
750
|
+
self.pos += 1
|
|
751
|
+
return _merge(mapping, merges)
|
|
752
|
+
|
|
753
|
+
def name(self) -> str:
|
|
754
|
+
# The name of an anchor or an alias, after its indicator
|
|
755
|
+
if not (m := re.compile(_ANCHOR).match(self.text, self.pos + 1)):
|
|
756
|
+
raise self.error()
|
|
757
|
+
self.pos = m.end()
|
|
758
|
+
return m[0]
|
|
759
|
+
|
|
760
|
+
def node(self) -> Any:
|
|
761
|
+
c = self.peek()
|
|
762
|
+
if c == '*':
|
|
763
|
+
return self.loader.alias(self.name())
|
|
764
|
+
if c == '&':
|
|
765
|
+
anchor = self.name()
|
|
766
|
+
self.loader.anchors[anchor] = value = self.node()
|
|
767
|
+
return value
|
|
768
|
+
if c == '[':
|
|
769
|
+
return self.sequence()
|
|
770
|
+
if c == '{':
|
|
771
|
+
return self.mapping()
|
|
772
|
+
if c in ('"', "'"):
|
|
773
|
+
return self.quoted()
|
|
774
|
+
return _resolve(self.plain(None))
|
|
775
|
+
|
|
776
|
+
def peek(self) -> str:
|
|
777
|
+
text = self.text
|
|
778
|
+
while self.pos < len(text) and text[self.pos] in ' \t':
|
|
779
|
+
self.pos += 1
|
|
780
|
+
if self.pos >= len(text):
|
|
781
|
+
raise self.error()
|
|
782
|
+
return text[self.pos]
|
|
783
|
+
|
|
784
|
+
def plain(self, end: str | None) -> str:
|
|
785
|
+
text = self.text
|
|
786
|
+
start = self.pos
|
|
787
|
+
while self.pos < len(text):
|
|
788
|
+
c = text[self.pos]
|
|
789
|
+
if c in ',[]{}' or c == ':' and (self.pos + 1 == len(text) or text[self.pos + 1] in ' ,[]{}'):
|
|
790
|
+
break
|
|
791
|
+
self.pos += 1
|
|
792
|
+
plain = text[start : self.pos].strip()
|
|
793
|
+
if not plain or re.search(r'^[!&*@`|>%#]|^-(?:\s|$)', plain):
|
|
794
|
+
raise self.error()
|
|
795
|
+
return plain
|
|
796
|
+
|
|
797
|
+
def quoted(self) -> str:
|
|
798
|
+
text = self.text
|
|
799
|
+
quote = text[self.pos]
|
|
800
|
+
pattern = _DOUBLE_QUOTED if quote == '"' else _SINGLE_QUOTED
|
|
801
|
+
if not (m := re.compile(pattern).match(text, self.pos)):
|
|
802
|
+
raise self.error()
|
|
803
|
+
self.pos = m.end()
|
|
804
|
+
return _unquote_double(m[1]) if quote == '"' else _unquote_single(m[1])
|
|
805
|
+
|
|
806
|
+
def sequence(self) -> list[Any]:
|
|
807
|
+
sequence: list[Any] = []
|
|
808
|
+
self.pos += 1
|
|
809
|
+
while self.peek() != ']':
|
|
810
|
+
sequence.append(self.entry(']')[0])
|
|
811
|
+
if self.peek() == ',':
|
|
812
|
+
self.pos += 1
|
|
813
|
+
elif self.peek() != ']':
|
|
814
|
+
raise self.error()
|
|
815
|
+
self.pos += 1
|
|
816
|
+
return sequence
|
|
817
|
+
|
|
818
|
+
def value(self) -> Any:
|
|
819
|
+
value = self.node()
|
|
820
|
+
if self.text[self.pos :].strip():
|
|
821
|
+
raise self.error()
|
|
822
|
+
return value
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
# Not in CPAN::Meta::YAML: the merge keys of a mapping, << of YAML 1.1, which PyYAML reads too: the keys of the
|
|
826
|
+
# mappings that they name, of the earlier ones first, with the keys of the mapping itself over them
|
|
827
|
+
def _merge(mapping: dict[Any, Any], merges: list[Any]) -> dict[Any, Any]:
|
|
828
|
+
if not merges:
|
|
829
|
+
return mapping
|
|
830
|
+
merged: dict[Any, Any] = {}
|
|
831
|
+
for merge in merges:
|
|
832
|
+
sources: list[Any] = cast(list[Any], merge) if isinstance(merge, list) else [merge]
|
|
833
|
+
for source in reversed(sources):
|
|
834
|
+
if not isinstance(source, dict):
|
|
835
|
+
raise _error('found a merge key with a value that is not a mapping or a sequence of mappings')
|
|
836
|
+
merged.update(cast(dict[Any, Any], source))
|
|
837
|
+
merged.update(mapping)
|
|
838
|
+
return merged
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
# The key at the start of a line, quoted, which only the pattern of its quotes reads, or not
|
|
842
|
+
def _key(line: str) -> re.Match[str] | None:
|
|
843
|
+
if (stripped := line.lstrip()).startswith("'"):
|
|
844
|
+
return _RE_KEY_SINGLE.search(line)
|
|
845
|
+
if stripped.startswith('"'):
|
|
846
|
+
return _RE_KEY_DOUBLE.search(line)
|
|
847
|
+
return _RE_KEY_UNQUOTED.search(line)
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _is_key(line: str) -> bool:
|
|
851
|
+
line = _RE_KEY_ANCHOR.sub(r'\1', line, count=1)
|
|
852
|
+
return _key(line) is not None or _RE_KEY_ALIAS.search(line) is not None
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
# The end of the flow collection at the start of a string, after its closing bracket, or None while it is open
|
|
856
|
+
def _flow_end(text: str) -> int | None:
|
|
857
|
+
depth = 0
|
|
858
|
+
quote = ''
|
|
859
|
+
i = 0
|
|
860
|
+
while i < len(text):
|
|
861
|
+
c = text[i]
|
|
862
|
+
if quote == '"':
|
|
863
|
+
if c == '\\':
|
|
864
|
+
i += 1
|
|
865
|
+
elif c == '"':
|
|
866
|
+
quote = ''
|
|
867
|
+
elif quote == "'":
|
|
868
|
+
if c == "'" and text[i + 1 : i + 2] == "'":
|
|
869
|
+
i += 1
|
|
870
|
+
elif c == "'":
|
|
871
|
+
quote = ''
|
|
872
|
+
elif c in ('"', "'"):
|
|
873
|
+
quote = c
|
|
874
|
+
elif c in '[{':
|
|
875
|
+
depth += 1
|
|
876
|
+
elif c in ']}':
|
|
877
|
+
depth -= 1
|
|
878
|
+
if depth == 0:
|
|
879
|
+
return i + 1
|
|
880
|
+
i += 1
|
|
881
|
+
return None
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
# The lines of a folded block scalar: a line break between two lines is a space, an empty line a line break, and the
|
|
885
|
+
# lines more indented keep their line breaks
|
|
886
|
+
def _fold(lines: list[str]) -> str:
|
|
887
|
+
folded = ''
|
|
888
|
+
previous: str | None = None
|
|
889
|
+
empty = 0
|
|
890
|
+
for line in lines:
|
|
891
|
+
if not line:
|
|
892
|
+
empty += 1
|
|
893
|
+
continue
|
|
894
|
+
if previous is None:
|
|
895
|
+
folded = '\n' * empty + line
|
|
896
|
+
elif not empty and not previous[:1].isspace() and not line[:1].isspace():
|
|
897
|
+
folded += ' ' + line
|
|
898
|
+
elif previous[:1].isspace() or line[:1].isspace():
|
|
899
|
+
folded += '\n' * (1 + empty) + line
|
|
900
|
+
else:
|
|
901
|
+
folded += '\n' * empty + line
|
|
902
|
+
previous = line
|
|
903
|
+
empty = 0
|
|
904
|
+
return folded
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def _rstrip_empty(lines: list[str]) -> list[str]:
|
|
908
|
+
end = len(lines)
|
|
909
|
+
while end and not lines[end - 1]:
|
|
910
|
+
end -= 1
|
|
911
|
+
return lines[:end]
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
# The characters that a double quoted scalar escapes, which a single quoted and a plain one cannot have, and the ones
|
|
915
|
+
# that a literal block scalar cannot have either, which are the same without the line break and the tab
|
|
916
|
+
_RE_ESCAPE = re.compile(r'["\\\x00-\x1f\x7f-\x9f\u2028\u2029\ufeff\ud800-\udfff\ufffe\uffff]')
|
|
917
|
+
_RE_UNQUOTABLE = re.compile(r'[\x00-\x1f\x7f-\x9f\u2028\u2029\ufeff\ud800-\udfff\ufffe\uffff]')
|
|
918
|
+
_RE_UNLITERAL = re.compile(r'[\x00-\x08\x0b-\x1f\x7f-\x9f\u2028\u2029\ufeff\ud800-\udfff\ufffe\uffff]')
|
|
919
|
+
|
|
920
|
+
_ESCAPED = {
|
|
921
|
+
'\x00': '\\0',
|
|
922
|
+
'\x07': '\\a',
|
|
923
|
+
'\x08': '\\b',
|
|
924
|
+
'\x09': '\\t',
|
|
925
|
+
'\x0a': '\\n',
|
|
926
|
+
'\x0b': '\\v',
|
|
927
|
+
'\x0c': '\\f',
|
|
928
|
+
'\x0d': '\\r',
|
|
929
|
+
'\x1b': '\\e',
|
|
930
|
+
'"': '\\"',
|
|
931
|
+
'\\': '\\\\',
|
|
932
|
+
'\x85': '\\N',
|
|
933
|
+
'\u2028': '\\L',
|
|
934
|
+
'\u2029': '\\P',
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
# The plain scalars that YAML 1.1 reads as other types than a string, as PyYAML does: booleans, numbers with
|
|
938
|
+
# underscores, of base 2 and 60, and timestamps
|
|
939
|
+
_RE_YAML11 = re.compile(
|
|
940
|
+
r'[yYnN]|yes|Yes|YES|no|No|NO|on|On|ON|off|Off|OFF|='
|
|
941
|
+
r'|[-+]?(?:0b[01_]+|0x[0-9a-fA-F_]+|[0-9][0-9_]*(?::[0-5]?[0-9])*(?:\.[0-9_]*)?|\.[0-9_]+)(?:[eE][-+]?[0-9]+)?'
|
|
942
|
+
r'|[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}(?:(?:[Tt]|[ \t]+)[0-9]{1,2}:[0-9]{2}:[0-9]{2}.*)?'
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
class _Dumper:
|
|
947
|
+
def __init__(
|
|
948
|
+
self,
|
|
949
|
+
indent: int,
|
|
950
|
+
indent_sequences: bool,
|
|
951
|
+
quote: str | None,
|
|
952
|
+
quote_keys: str | None,
|
|
953
|
+
multiline: bool,
|
|
954
|
+
version: str,
|
|
955
|
+
anchors: bool,
|
|
956
|
+
sort_keys: bool = False,
|
|
957
|
+
) -> None:
|
|
958
|
+
if indent < 1:
|
|
959
|
+
raise ValueError(f'indent must be 1 or more, not {indent}')
|
|
960
|
+
for name, value in (('quote', quote), ('quote_keys', quote_keys)):
|
|
961
|
+
if value not in (None, "'", '"'):
|
|
962
|
+
raise ValueError(f"""{name} must be "'", '"' or None, not {value!r}""")
|
|
963
|
+
if version not in ('1.1', '1.2'):
|
|
964
|
+
raise ValueError(f"version must be '1.1' or '1.2', not {version!r}")
|
|
965
|
+
self.anchors = anchors
|
|
966
|
+
self.sort_keys = sort_keys
|
|
967
|
+
self.counts: dict[int, int] = {}
|
|
968
|
+
self.indent = indent
|
|
969
|
+
self.indent_sequences = indent_sequences
|
|
970
|
+
self.multiline = multiline
|
|
971
|
+
self.names: dict[int, str] = {}
|
|
972
|
+
self.parents: set[int] = set()
|
|
973
|
+
self.quote = quote
|
|
974
|
+
self.quote_keys = quote_keys
|
|
975
|
+
self.yaml11 = version == '1.1'
|
|
976
|
+
|
|
977
|
+
def collection(self, value: Any, column: int, name: str) -> list[str] | None:
|
|
978
|
+
# The lines of a collection that is not empty, which is not a scalar, or None
|
|
979
|
+
if not value or not isinstance(value, (dict, list, tuple)):
|
|
980
|
+
return None
|
|
981
|
+
collection = cast(dict[Any, Any] | list[Any] | tuple[Any, ...], value)
|
|
982
|
+
if id(collection) in self.parents:
|
|
983
|
+
raise _error('found a collection inside itself')
|
|
984
|
+
self.parents.add(id(collection))
|
|
985
|
+
if isinstance(collection, dict):
|
|
986
|
+
lines = self.mapping(collection, column)
|
|
987
|
+
else:
|
|
988
|
+
lines = self.sequence(collection, column, name)
|
|
989
|
+
self.parents.remove(id(collection))
|
|
990
|
+
return lines
|
|
991
|
+
|
|
992
|
+
def count(self, value: Any) -> None:
|
|
993
|
+
# The times that each dict and list is there, and the ones inside it, the first time
|
|
994
|
+
if isinstance(value, tuple):
|
|
995
|
+
for item in cast(tuple[Any, ...], value):
|
|
996
|
+
self.count(item)
|
|
997
|
+
elif isinstance(value, (dict, list)):
|
|
998
|
+
collection = cast(dict[Any, Any] | list[Any], value)
|
|
999
|
+
self.counts[id(collection)] = count = self.counts.get(id(collection), 0) + 1
|
|
1000
|
+
if count == 1:
|
|
1001
|
+
for item in collection.values() if isinstance(collection, dict) else collection:
|
|
1002
|
+
self.count(item)
|
|
1003
|
+
|
|
1004
|
+
def document(self, value: Any, start: bool) -> list[str]:
|
|
1005
|
+
# The anchors of a document are its own
|
|
1006
|
+
self.counts = {}
|
|
1007
|
+
self.names = {}
|
|
1008
|
+
if self.anchors:
|
|
1009
|
+
self.count(value)
|
|
1010
|
+
|
|
1011
|
+
if (lines := self.collection(value := _data(value), 0, 'id')) is not None:
|
|
1012
|
+
return ['---', *lines] if start else lines
|
|
1013
|
+
|
|
1014
|
+
# A scalar after the start of its document, where the loader reads it
|
|
1015
|
+
text, block = self.scalar(value, 0)
|
|
1016
|
+
return [f'--- {text}', *block]
|
|
1017
|
+
|
|
1018
|
+
def key(self, key: Any) -> str:
|
|
1019
|
+
if isinstance(key, str):
|
|
1020
|
+
return self.string(str.__str__(key), 0, self.quote_keys, False, True)[0]
|
|
1021
|
+
if (text := _plain_value(key)) is None:
|
|
1022
|
+
raise _error(f"does not support a key of the type '{type(key).__name__}'")
|
|
1023
|
+
return text
|
|
1024
|
+
|
|
1025
|
+
def literal(self, string: str, column: int) -> tuple[str, list[str]] | None:
|
|
1026
|
+
# A literal block scalar, unless the loader would not read its lines back: its first line that is not empty
|
|
1027
|
+
# starts with a space, which needs the indicator of the indentation, or a line has only spaces
|
|
1028
|
+
body = string.rstrip('\n')
|
|
1029
|
+
lines = body.split('\n')
|
|
1030
|
+
first = next((line for line in lines if line), '')
|
|
1031
|
+
if not first or first[:1].isspace() or _RE_UNLITERAL.search(body):
|
|
1032
|
+
return None
|
|
1033
|
+
if any(line and not line.strip() for line in lines):
|
|
1034
|
+
return None
|
|
1035
|
+
|
|
1036
|
+
# The final line breaks, which the chomping indicator strips, clips to one or keeps
|
|
1037
|
+
breaks = len(string) - len(body)
|
|
1038
|
+
chomping = '-' if breaks == 0 else '' if breaks == 1 else '+'
|
|
1039
|
+
pad = ' ' * (column + self.indent)
|
|
1040
|
+
return f'|{chomping}', [pad + line if line else '' for line in lines + [''] * (breaks - 1)]
|
|
1041
|
+
|
|
1042
|
+
def mapping(self, mapping: dict[Any, Any], column: int) -> list[str]:
|
|
1043
|
+
lines: list[str] = []
|
|
1044
|
+
items = list(mapping.items())
|
|
1045
|
+
if self.sort_keys:
|
|
1046
|
+
items.sort(key=lambda item: str(item[0]))
|
|
1047
|
+
for key, value in items:
|
|
1048
|
+
prefix = ' ' * column + (text := self.key(key)) + ':'
|
|
1049
|
+
name = key if isinstance(key, str) else text
|
|
1050
|
+
value = _data(value)
|
|
1051
|
+
nested = column + self.indent if isinstance(value, dict) or self.indent_sequences else column
|
|
1052
|
+
if (reference := self.reference(value, name)).startswith('*'):
|
|
1053
|
+
lines.append(f'{prefix} {reference}')
|
|
1054
|
+
elif (below := self.collection(value, nested, name)) is not None:
|
|
1055
|
+
lines += [f'{prefix} {reference}' if reference else prefix, *below]
|
|
1056
|
+
else:
|
|
1057
|
+
text, block = self.scalar(value, column)
|
|
1058
|
+
lines += [f'{prefix} {reference} {text}' if reference else f'{prefix} {text}', *block]
|
|
1059
|
+
return lines
|
|
1060
|
+
|
|
1061
|
+
def plain(self, string: str, key: bool) -> bool:
|
|
1062
|
+
# A string that the loader reads back as the same string without quotes
|
|
1063
|
+
if not string or string != string.strip() or _RE_UNQUOTABLE.search(string):
|
|
1064
|
+
return False
|
|
1065
|
+
if string[0] in '?:,[]{}#&*!|>\'"%@`' or re.search(r'^(?:-(?:\s|\Z|--)|\.\.\.)|\s#|:(?:\s|\Z)', string):
|
|
1066
|
+
return False
|
|
1067
|
+
if key and (string[0] == '-' or string == '<<'):
|
|
1068
|
+
return False
|
|
1069
|
+
if not isinstance(_resolve(string), str):
|
|
1070
|
+
return False
|
|
1071
|
+
return not (self.yaml11 and _RE_YAML11.fullmatch(string))
|
|
1072
|
+
|
|
1073
|
+
def reference(self, value: Any, name: str) -> str:
|
|
1074
|
+
# The anchor of a dict or a list that is there more than once, where it is first, with the name of its key and
|
|
1075
|
+
# a number for another one of the name, and its alias after it
|
|
1076
|
+
if not isinstance(value, (dict, list)) or self.counts.get(key := id(cast(Any, value)), 0) < 2:
|
|
1077
|
+
return ''
|
|
1078
|
+
if (anchor := self.names.get(key)) is None:
|
|
1079
|
+
anchor = name = re.sub(r'[^0-9A-Za-z_-]+', '_', name).strip('_') or 'id'
|
|
1080
|
+
number = 1
|
|
1081
|
+
while anchor in self.names.values():
|
|
1082
|
+
number += 1
|
|
1083
|
+
anchor = f'{name}{number}'
|
|
1084
|
+
self.names[key] = anchor
|
|
1085
|
+
return f'&{anchor}'
|
|
1086
|
+
if key in self.parents:
|
|
1087
|
+
raise _error('found a collection inside itself')
|
|
1088
|
+
return f'*{anchor}'
|
|
1089
|
+
|
|
1090
|
+
def scalar(self, value: Any, column: int) -> tuple[str, list[str]]:
|
|
1091
|
+
if isinstance(value, str):
|
|
1092
|
+
return self.string(str.__str__(value), column, self.quote, self.multiline)
|
|
1093
|
+
if (text := _plain_value(value)) is not None:
|
|
1094
|
+
return text, []
|
|
1095
|
+
if isinstance(value, dict):
|
|
1096
|
+
return '{}', []
|
|
1097
|
+
if isinstance(value, (list, tuple)):
|
|
1098
|
+
return '[]', []
|
|
1099
|
+
if isinstance(value, (bytes, bytearray)):
|
|
1100
|
+
return '!!binary ' + base64.b64encode(value).decode(), []
|
|
1101
|
+
if isinstance(value, datetime.date):
|
|
1102
|
+
return '!!timestamp ' + value.isoformat(), []
|
|
1103
|
+
raise _error(f"does not support a value of the type '{type(value).__name__}'")
|
|
1104
|
+
|
|
1105
|
+
def sequence(self, sequence: list[Any] | tuple[Any, ...], column: int, name: str) -> list[str]:
|
|
1106
|
+
lines: list[str] = []
|
|
1107
|
+
pad = ' ' * column
|
|
1108
|
+
for item in sequence:
|
|
1109
|
+
# A collection that starts on the line of the item, or below its anchor
|
|
1110
|
+
item = _data(item)
|
|
1111
|
+
if (reference := self.reference(item, name)).startswith('*'):
|
|
1112
|
+
lines.append(f'{pad}- {reference}')
|
|
1113
|
+
elif (nested := self.collection(item, column + 2, name)) is not None:
|
|
1114
|
+
if reference:
|
|
1115
|
+
lines += [f'{pad}- {reference}', *nested]
|
|
1116
|
+
else:
|
|
1117
|
+
lines += [f'{pad}- {nested[0][column + 2 :]}', *nested[1:]]
|
|
1118
|
+
else:
|
|
1119
|
+
text, block = self.scalar(item, column)
|
|
1120
|
+
lines += [f'{pad}- {reference} {text}' if reference else f'{pad}- {text}', *block]
|
|
1121
|
+
return lines
|
|
1122
|
+
|
|
1123
|
+
def string(
|
|
1124
|
+
self, string: str, column: int, quote: str | None, multiline: bool, key: bool = False
|
|
1125
|
+
) -> tuple[str, list[str]]:
|
|
1126
|
+
if multiline and '\n' in string and (block := self.literal(string, column)) is not None:
|
|
1127
|
+
return block
|
|
1128
|
+
if quote is None and self.plain(string, key):
|
|
1129
|
+
return string, []
|
|
1130
|
+
if quote != '"' and not _RE_UNQUOTABLE.search(string):
|
|
1131
|
+
return "'" + string.replace("'", "''") + "'", []
|
|
1132
|
+
return '"' + _RE_ESCAPE.sub(_escape, string) + '"', []
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
# An object that Pyjo.JSON encodes, as the value of its __json__ method, or as a string where its class has its own
|
|
1136
|
+
# __str__
|
|
1137
|
+
def _data(value: Any) -> Any:
|
|
1138
|
+
if value is None or isinstance(value, (bool, int, float, str, bytes, bytearray, dict, list, tuple, datetime.date)):
|
|
1139
|
+
return cast(Any, value)
|
|
1140
|
+
if (method := getattr(value, '__json__', None)) is not None:
|
|
1141
|
+
return method()
|
|
1142
|
+
if type(value).__str__ is not builtins.object.__str__:
|
|
1143
|
+
return str(value)
|
|
1144
|
+
return value
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def _escape(m: re.Match[str]) -> str:
|
|
1148
|
+
if (escaped := _ESCAPED.get(c := m[0])) is not None:
|
|
1149
|
+
return escaped
|
|
1150
|
+
return f'\\x{ord(c):02x}' if ord(c) < 0x100 else f'\\u{ord(c):04x}'
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
# The text of a null, a boolean or a number, which the loader reads back as the same value
|
|
1154
|
+
def _plain_value(value: Any) -> str | None:
|
|
1155
|
+
if value is None:
|
|
1156
|
+
return 'null'
|
|
1157
|
+
if isinstance(value, bool):
|
|
1158
|
+
return 'true' if value else 'false'
|
|
1159
|
+
if isinstance(value, int):
|
|
1160
|
+
return int.__repr__(value)
|
|
1161
|
+
if isinstance(value, float):
|
|
1162
|
+
if math.isnan(value):
|
|
1163
|
+
return '.nan'
|
|
1164
|
+
if math.isinf(value):
|
|
1165
|
+
return '.inf' if value > 0 else '-.inf'
|
|
1166
|
+
return float.__repr__(value)
|
|
1167
|
+
return None
|