cv-tools-cli 2.0.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.
- cv_tools/__init__.py +7 -0
- cv_tools/__main__.py +5 -0
- cv_tools/api.py +123 -0
- cv_tools/bib.py +122 -0
- cv_tools/charset.py +142 -0
- cv_tools/cli.py +384 -0
- cv_tools/doctor.py +185 -0
- cv_tools/engine.py +320 -0
- cv_tools/loader.py +424 -0
- cv_tools/markup.py +614 -0
- cv_tools/migrate.py +222 -0
- cv_tools/model.py +192 -0
- cv_tools/render/__init__.py +1 -0
- cv_tools/render/html.py +581 -0
- cv_tools/render/latex.py +154 -0
- cv_tools/schema/cv-v2.schema.json +707 -0
- cv_tools/starter/cv.yaml +129 -0
- cv_tools/starter/publications.bib +55 -0
- cv_tools/templates/html/cv.css +252 -0
- cv_tools/templates/html/cv.html.jinja +19 -0
- cv_tools/templates/html/icons/github.svg +1 -0
- cv_tools/templates/html/icons/linkedin.svg +1 -0
- cv_tools/templates/html/icons/orcid.svg +1 -0
- cv_tools/templates/html/icons/twitter.svg +1 -0
- cv_tools/templates/html/macros.jinja +89 -0
- cv_tools/templates/latex/cv.tex.jinja +45 -0
- cv_tools/templates/latex/layouts/dated.jinja +12 -0
- cv_tools/templates/latex/layouts/entries-inline.jinja +23 -0
- cv_tools/templates/latex/layouts/entries.jinja +29 -0
- cv_tools/templates/latex/layouts/list.jinja +24 -0
- cv_tools/templates/latex/layouts/pairs.jinja +14 -0
- cv_tools/templates/latex/layouts/publications.jinja +14 -0
- cv_tools/templates/latex/layouts/text.jinja +10 -0
- cv_tools/templates/latex/orcid.pdf +0 -0
- cv_tools/templates/latex/packages.txt +47 -0
- cv_tools/templates/latex/resume.cls +265 -0
- cv_tools/templates/latex/unicode.txt +398 -0
- cv_tools/v1.py +484 -0
- cv_tools/validate.py +165 -0
- cv_tools/watch.py +76 -0
- cv_tools_cli-2.0.0.dist-info/METADATA +498 -0
- cv_tools_cli-2.0.0.dist-info/RECORD +46 -0
- cv_tools_cli-2.0.0.dist-info/WHEEL +5 -0
- cv_tools_cli-2.0.0.dist-info/entry_points.txt +2 -0
- cv_tools_cli-2.0.0.dist-info/licenses/LICENSE +21 -0
- cv_tools_cli-2.0.0.dist-info/top_level.txt +1 -0
cv_tools/loader.py
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""YAML loading with duplicate-key rejection and source positions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Optional, Union
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
from yaml.events import AliasEvent
|
|
13
|
+
from yaml.nodes import MappingNode, Node, ScalarNode, SequenceNode
|
|
14
|
+
|
|
15
|
+
YamlPath = tuple[Union[str, int], ...]
|
|
16
|
+
|
|
17
|
+
_TIMESTAMP = "tag:yaml.org,2002:timestamp"
|
|
18
|
+
_MERGE = "tag:yaml.org,2002:merge"
|
|
19
|
+
_MERGE_KEY = object()
|
|
20
|
+
|
|
21
|
+
# Stops alias bombs ("billion laughs"); far more than any CV repeats.
|
|
22
|
+
MAX_ALIAS_NODES = 10_000
|
|
23
|
+
|
|
24
|
+
# Deeper YAML would exhaust Python's recursion limit while it is read.
|
|
25
|
+
MAX_DEPTH = 100
|
|
26
|
+
|
|
27
|
+
# sys.get_int_max_str_digits: longer integers load as the string written.
|
|
28
|
+
_MAX_INT_DIGITS = 4300
|
|
29
|
+
_INT_LIMIT = 10**_MAX_INT_DIGITS
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CVError(Exception):
|
|
33
|
+
"""An error in a CV file, printed as ``file:line:col: message``."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, message: str, file: str | None = None, line: int | None = None, col: int | None = None):
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.message = message
|
|
38
|
+
self.file = file
|
|
39
|
+
self.line = line
|
|
40
|
+
self.col = col
|
|
41
|
+
|
|
42
|
+
def __str__(self) -> str:
|
|
43
|
+
return f"{self.location}: {self.message}" if self.location else self.message
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def location(self) -> str:
|
|
47
|
+
return ":".join(str(p) for p in (self.file, self.line, self.col) if p is not None)
|
|
48
|
+
|
|
49
|
+
def format(self, severity: str = "error") -> str:
|
|
50
|
+
return f"{self.location}: {severity}: {self.message}" if self.location else f"{severity}: {self.message}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Source:
|
|
55
|
+
"""A parsed YAML document and the position of every node in it."""
|
|
56
|
+
|
|
57
|
+
data: Any
|
|
58
|
+
name: str
|
|
59
|
+
text: str
|
|
60
|
+
base_dir: Path
|
|
61
|
+
positions: dict[YamlPath, tuple[int, int]] = field(default_factory=dict)
|
|
62
|
+
key_positions: dict[YamlPath, tuple[int, int]] = field(default_factory=dict)
|
|
63
|
+
yaml_version: str = "1.1" # "1.2" for a v2 file
|
|
64
|
+
# Paths reached through an alias or merge key, and paths inside a used
|
|
65
|
+
# anchor, each with its anchor name (web-spec section 4.3).
|
|
66
|
+
aliases: dict[YamlPath, str] = field(default_factory=dict)
|
|
67
|
+
anchors: dict[YamlPath, str] = field(default_factory=dict)
|
|
68
|
+
|
|
69
|
+
def position(self, path: YamlPath, key: bool = False) -> tuple[int, int] | None:
|
|
70
|
+
"""1-based (line, column) of ``path`` or its closest ancestor; ``key`` prefers the key's position."""
|
|
71
|
+
path = tuple(path)
|
|
72
|
+
if key and path in self.key_positions:
|
|
73
|
+
return self.key_positions[path]
|
|
74
|
+
while path not in self.positions and path:
|
|
75
|
+
path = path[:-1]
|
|
76
|
+
return self.positions.get(path)
|
|
77
|
+
|
|
78
|
+
def error(self, message: str, path: YamlPath = (), key: bool = False) -> CVError:
|
|
79
|
+
pos = self.position(path, key)
|
|
80
|
+
return CVError(message, self.name, *(pos or (None, None)))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class _Loader(yaml.SafeLoader):
|
|
84
|
+
"""YAML 1.1 as v1 read it, but with duplicate keys rejected and timestamps kept as strings."""
|
|
85
|
+
|
|
86
|
+
yaml_implicit_resolvers = {
|
|
87
|
+
first: [r for r in resolvers if r[0] != _TIMESTAMP]
|
|
88
|
+
for first, resolvers in yaml.SafeLoader.yaml_implicit_resolvers.items()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
def __init__(self, stream):
|
|
92
|
+
super().__init__(stream)
|
|
93
|
+
self.anchor_of: dict[int, str] = {}
|
|
94
|
+
self.aliased: set[int] = set()
|
|
95
|
+
# (id(parent), index or id(key node)) -> (anchor, mark)
|
|
96
|
+
self.alias_slots: dict[tuple[int, Any], tuple[str, Any]] = {}
|
|
97
|
+
# (id(mapping), index of the pair) -> (anchor, mark), for aliases used as keys
|
|
98
|
+
self.alias_keys: dict[tuple[int, int], tuple[str, Any]] = {}
|
|
99
|
+
self.depth = 0
|
|
100
|
+
|
|
101
|
+
def compose_node(self, parent, index):
|
|
102
|
+
event = self.peek_event()
|
|
103
|
+
self.depth += 1
|
|
104
|
+
if self.depth > MAX_DEPTH:
|
|
105
|
+
raise yaml.composer.ComposerError(
|
|
106
|
+
None, None, f"the YAML is nested more than {MAX_DEPTH} levels deep", event.start_mark)
|
|
107
|
+
try:
|
|
108
|
+
node = super().compose_node(parent, index)
|
|
109
|
+
finally:
|
|
110
|
+
self.depth -= 1
|
|
111
|
+
if isinstance(event, AliasEvent):
|
|
112
|
+
self.aliased.add(id(node))
|
|
113
|
+
if parent is not None and index is not None:
|
|
114
|
+
slot = id(index) if isinstance(index, Node) else index
|
|
115
|
+
self.alias_slots[(id(parent), slot)] = (event.anchor, event.start_mark)
|
|
116
|
+
elif isinstance(parent, MappingNode):
|
|
117
|
+
self.alias_keys[(id(parent), len(parent.value))] = (event.anchor, event.start_mark)
|
|
118
|
+
elif event.anchor is not None:
|
|
119
|
+
self.anchor_of[id(node)] = event.anchor
|
|
120
|
+
return node
|
|
121
|
+
|
|
122
|
+
def construct_yaml_int(self, node):
|
|
123
|
+
value = self.construct_scalar(node)
|
|
124
|
+
try:
|
|
125
|
+
number = super().construct_yaml_int(node)
|
|
126
|
+
except ValueError:
|
|
127
|
+
return value
|
|
128
|
+
return value if abs(number) >= _INT_LIMIT else number
|
|
129
|
+
|
|
130
|
+
def construct_yaml_float(self, node):
|
|
131
|
+
return _float_or_text(self.construct_scalar(node), super().construct_yaml_float(node))
|
|
132
|
+
|
|
133
|
+
def construct_mapping(self, node, deep=False):
|
|
134
|
+
if isinstance(node, MappingNode):
|
|
135
|
+
seen: dict[Any, Any] = {}
|
|
136
|
+
for key_node, _ in node.value:
|
|
137
|
+
merge = key_node.tag == _MERGE
|
|
138
|
+
key = _MERGE_KEY if merge else self.construct_object(key_node, deep=True)
|
|
139
|
+
try:
|
|
140
|
+
first = seen.get(key)
|
|
141
|
+
except TypeError:
|
|
142
|
+
continue # unhashable; the base class reports it
|
|
143
|
+
if first is not None:
|
|
144
|
+
raise yaml.constructor.ConstructorError(
|
|
145
|
+
None,
|
|
146
|
+
None,
|
|
147
|
+
f"duplicate key '{'<<' if merge else key}' (first defined on line {first.line + 1})",
|
|
148
|
+
key_node.start_mark,
|
|
149
|
+
)
|
|
150
|
+
seen[key] = key_node.start_mark
|
|
151
|
+
return super().construct_mapping(node, deep)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
_Loader.add_constructor("tag:yaml.org,2002:int", _Loader.construct_yaml_int)
|
|
155
|
+
_Loader.add_constructor("tag:yaml.org,2002:float", _Loader.construct_yaml_float)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _float_or_text(text: str, number: float) -> Union[float, str]:
|
|
159
|
+
# Out of range: keep the text written, as for huge integers.
|
|
160
|
+
if math.isinf(number) and "inf" not in text.lower():
|
|
161
|
+
return text
|
|
162
|
+
if number == 0 and re.search("[1-9]", text.lower().partition("e")[0]):
|
|
163
|
+
return text
|
|
164
|
+
return number
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# YAML 1.2 core schema, as JSON Schema and JavaScript YAML libraries read it.
|
|
168
|
+
_CORE_RESOLVERS = [
|
|
169
|
+
("tag:yaml.org,2002:bool", r"^(?:true|True|TRUE|false|False|FALSE)$", "tTfF"),
|
|
170
|
+
("tag:yaml.org,2002:int", r"^(?:[-+]?[0-9]+|0o[0-7]+|0x[0-9a-fA-F]+)$", "-+0123456789"),
|
|
171
|
+
(
|
|
172
|
+
"tag:yaml.org,2002:float",
|
|
173
|
+
r"^(?:[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)(?:[eE][-+]?[0-9]+)?"
|
|
174
|
+
r"|[-+]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN))$",
|
|
175
|
+
"-+.0123456789",
|
|
176
|
+
),
|
|
177
|
+
("tag:yaml.org,2002:null", r"^(?:~|null|Null|NULL|)$", ["~", "n", "N", ""]),
|
|
178
|
+
("tag:yaml.org,2002:merge", r"^(?:<<)$", "<"),
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class _CoreLoader(_Loader):
|
|
183
|
+
"""YAML 1.2 core schema scalars, for v2 files (``yes`` and ``10:30`` stay strings)."""
|
|
184
|
+
|
|
185
|
+
yaml_implicit_resolvers: dict = {}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
for _tag, _pattern, _first in _CORE_RESOLVERS:
|
|
189
|
+
_CoreLoader.add_implicit_resolver(_tag, re.compile(_pattern), list(_first))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _core_int(loader: _CoreLoader, node: ScalarNode) -> Union[int, str]:
|
|
193
|
+
value = loader.construct_scalar(node)
|
|
194
|
+
if value.startswith("0o"):
|
|
195
|
+
number = int(value[2:], 8)
|
|
196
|
+
elif value.startswith("0x"):
|
|
197
|
+
number = int(value[2:], 16)
|
|
198
|
+
else:
|
|
199
|
+
digits = value.lstrip("+-").lstrip("0")
|
|
200
|
+
if len(digits) > _MAX_INT_DIGITS:
|
|
201
|
+
return value
|
|
202
|
+
number = int((value[0] if value[0] in "+-" else "") + (digits or "0"), 10)
|
|
203
|
+
return value if abs(number) >= _INT_LIMIT else number
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _core_float(loader: _CoreLoader, node: ScalarNode) -> Union[float, str]:
|
|
207
|
+
text = loader.construct_scalar(node)
|
|
208
|
+
value = text.lower()
|
|
209
|
+
sign = -1.0 if value.startswith("-") else 1.0
|
|
210
|
+
if value.lstrip("+-") == ".inf":
|
|
211
|
+
return sign * float("inf")
|
|
212
|
+
if value == ".nan":
|
|
213
|
+
return float("nan")
|
|
214
|
+
return _float_or_text(text, float(value))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
_CoreLoader.add_constructor("tag:yaml.org,2002:int", _core_int)
|
|
218
|
+
_CoreLoader.add_constructor("tag:yaml.org,2002:float", _core_float)
|
|
219
|
+
|
|
220
|
+
_LOADERS = {"1.1": _Loader, "1.2": _CoreLoader}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def describe(value: Any) -> str:
|
|
224
|
+
"""The type of a YAML value as error messages name it."""
|
|
225
|
+
if value is None:
|
|
226
|
+
return "null"
|
|
227
|
+
if isinstance(value, bool):
|
|
228
|
+
return "a boolean"
|
|
229
|
+
if isinstance(value, (int, float)):
|
|
230
|
+
return "a number"
|
|
231
|
+
if isinstance(value, str):
|
|
232
|
+
return "a string"
|
|
233
|
+
if isinstance(value, list):
|
|
234
|
+
return "a list"
|
|
235
|
+
if isinstance(value, dict):
|
|
236
|
+
return "a mapping"
|
|
237
|
+
return type(value).__name__
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _mark(m) -> tuple[int, int]:
|
|
241
|
+
return (m.line + 1, m.column + 1)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class _Walk:
|
|
245
|
+
"""Records positions, aliases and anchors, and enforces the alias budget."""
|
|
246
|
+
|
|
247
|
+
def __init__(self, loader: _Loader, src: Source):
|
|
248
|
+
self.loader = loader
|
|
249
|
+
self.src = src
|
|
250
|
+
self.stack: set[int] = set()
|
|
251
|
+
self.expanded = 0
|
|
252
|
+
self.anchor_around: dict[int, str] = {}
|
|
253
|
+
|
|
254
|
+
def alias(self, parent: Node, index: Any) -> Optional[tuple[str, Any]]:
|
|
255
|
+
slot = id(index) if isinstance(index, Node) else index
|
|
256
|
+
return self.loader.alias_slots.get((id(parent), slot))
|
|
257
|
+
|
|
258
|
+
def members(self, node: MappingNode, outer: Optional[tuple[str, Any]]) -> list[tuple]:
|
|
259
|
+
"""``(owner, key node, value node, via, outermost via)`` per key, merges resolved as PyYAML does."""
|
|
260
|
+
out: list[tuple] = []
|
|
261
|
+
seen: set[Any] = set()
|
|
262
|
+
|
|
263
|
+
def add(mapping: MappingNode, via: Optional[tuple[str, Any]], top: Optional[tuple[str, Any]],
|
|
264
|
+
depth: int) -> None:
|
|
265
|
+
if depth:
|
|
266
|
+
if id(mapping) in self.stack or depth > 64:
|
|
267
|
+
raise self._error("a merge key refers to a mapping that contains it", via, mapping)
|
|
268
|
+
# Each merged mapping counts, or a merge bomb would walk forever.
|
|
269
|
+
self.count(outer or top, mapping)
|
|
270
|
+
merges = []
|
|
271
|
+
for i, (key_node, value_node) in enumerate(mapping.value):
|
|
272
|
+
if key_node.tag == _MERGE:
|
|
273
|
+
merges.append((key_node, value_node))
|
|
274
|
+
continue
|
|
275
|
+
if not isinstance(key_node, ScalarNode):
|
|
276
|
+
continue
|
|
277
|
+
key = self.loader.construct_object(key_node)
|
|
278
|
+
if key not in seen:
|
|
279
|
+
seen.add(key)
|
|
280
|
+
# A value under an alias key has no path that setIn can reach.
|
|
281
|
+
key_alias = self.loader.alias_keys.get((id(mapping), i))
|
|
282
|
+
out.append((mapping, key_node, value_node, via or key_alias, top or key_alias))
|
|
283
|
+
for key_node, value_node in merges:
|
|
284
|
+
alias = self.alias(mapping, key_node)
|
|
285
|
+
if isinstance(value_node, MappingNode):
|
|
286
|
+
sources = [(value_node, alias)]
|
|
287
|
+
elif isinstance(value_node, SequenceNode):
|
|
288
|
+
sources = [(m, self.alias(value_node, i)) for i, m in enumerate(value_node.value)]
|
|
289
|
+
else:
|
|
290
|
+
continue # not a mapping; the constructor reports it
|
|
291
|
+
for source, source_alias in sources:
|
|
292
|
+
if not isinstance(source, MappingNode):
|
|
293
|
+
continue
|
|
294
|
+
name = source_alias or (self.loader.anchor_of.get(id(source), "<<"), source.start_mark)
|
|
295
|
+
add(source, name, top or name, depth + 1)
|
|
296
|
+
|
|
297
|
+
add(node, None, None, 0)
|
|
298
|
+
return out
|
|
299
|
+
|
|
300
|
+
def count(self, outer: Optional[tuple[str, Any]], node: Node) -> None:
|
|
301
|
+
self.expanded += 1
|
|
302
|
+
if self.expanded > MAX_ALIAS_NODES:
|
|
303
|
+
raise self._error(
|
|
304
|
+
f"aliases expand to more than {MAX_ALIAS_NODES} nodes; "
|
|
305
|
+
"write the repeated values out or use fewer aliases", outer, node)
|
|
306
|
+
|
|
307
|
+
def _error(self, message: str, via: Optional[tuple[str, Any]], node: Node) -> CVError:
|
|
308
|
+
mark = via[1] if via else node.start_mark
|
|
309
|
+
return CVError(message, self.src.name, *_mark(mark))
|
|
310
|
+
|
|
311
|
+
def record(self, node: Node, path: YamlPath, via: Optional[str], anchor: Optional[str],
|
|
312
|
+
alias: Optional[tuple[str, Any]] = None, outer: Optional[tuple[str, Any]] = None) -> None:
|
|
313
|
+
"""Record ``node`` and everything below it. ``via`` is the last alias on
|
|
314
|
+
the path, ``outer`` the first one (where a budget error points)."""
|
|
315
|
+
src = self.src
|
|
316
|
+
src.positions.setdefault(path, _mark(node.start_mark))
|
|
317
|
+
if alias is not None:
|
|
318
|
+
if id(node) in self.stack:
|
|
319
|
+
raise self._error(f"alias *{alias[0]} refers to a node that contains it", alias, node)
|
|
320
|
+
via, outer = alias[0], outer or alias
|
|
321
|
+
if via is not None:
|
|
322
|
+
self.count(outer, node)
|
|
323
|
+
# The anchor around the original, so it matches that place's data-anchor.
|
|
324
|
+
src.aliases[path] = self.anchor_around.get(id(node), via)
|
|
325
|
+
else:
|
|
326
|
+
if id(node) in self.loader.aliased and id(node) in self.loader.anchor_of:
|
|
327
|
+
anchor = self.loader.anchor_of[id(node)]
|
|
328
|
+
if anchor is not None:
|
|
329
|
+
src.anchors[path] = anchor
|
|
330
|
+
self.anchor_around.setdefault(id(node), anchor)
|
|
331
|
+
if not isinstance(node, (MappingNode, SequenceNode)):
|
|
332
|
+
return
|
|
333
|
+
self.stack.add(id(node))
|
|
334
|
+
if isinstance(node, MappingNode):
|
|
335
|
+
for owner, key_node, value_node, merged, top in self.members(node, outer):
|
|
336
|
+
child = path + (self.loader.construct_object(key_node),)
|
|
337
|
+
src.key_positions.setdefault(child, _mark(key_node.start_mark))
|
|
338
|
+
child_via, child_outer = (merged[0], outer or top) if merged else (via, outer)
|
|
339
|
+
self.record(value_node, child, child_via, anchor, self.alias(owner, key_node), child_outer)
|
|
340
|
+
else:
|
|
341
|
+
for i, item in enumerate(node.value):
|
|
342
|
+
self.record(item, path + (i,), via, anchor, self.alias(node, i), outer)
|
|
343
|
+
self.stack.discard(id(node))
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _record(loader: _Loader, node: Node, src: Source) -> None:
|
|
347
|
+
_Walk(loader, src).record(node, (), None, None)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def load_text(text: str, name: str = "<string>", base_dir: Path | None = None, yaml_version: str = "auto") -> Source:
|
|
351
|
+
"""Parse YAML text, raising :class:`CVError` with a position on any error.
|
|
352
|
+
|
|
353
|
+
``"auto"`` reads YAML 1.2 when the file has a ``version`` other than 1, else YAML 1.1.
|
|
354
|
+
"""
|
|
355
|
+
if yaml_version != "auto":
|
|
356
|
+
return _parse(text, name, base_dir, _LOADERS[yaml_version])
|
|
357
|
+
try:
|
|
358
|
+
src = _parse(text, name, base_dir, _CoreLoader)
|
|
359
|
+
except CVError as e:
|
|
360
|
+
try:
|
|
361
|
+
src = _parse(text, name, base_dir, _Loader)
|
|
362
|
+
except CVError:
|
|
363
|
+
raise e from None
|
|
364
|
+
if isinstance(src.data, dict) and "version" in src.data:
|
|
365
|
+
raise e from None
|
|
366
|
+
return src
|
|
367
|
+
if isinstance(src.data, dict) and "version" in src.data and not _is_v1(src.data["version"]):
|
|
368
|
+
return src
|
|
369
|
+
return _parse(text, name, base_dir, _Loader)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _is_v1(version: Any) -> bool:
|
|
373
|
+
return not isinstance(version, bool) and isinstance(version, (int, float)) and version == 1
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _parse(text: str, name: str, base_dir: Path | None, loader_class: type) -> Source:
|
|
377
|
+
src = Source(None, name, text, base_dir or Path.cwd())
|
|
378
|
+
src.yaml_version = "1.2" if loader_class is _CoreLoader else "1.1"
|
|
379
|
+
loader = None
|
|
380
|
+
try:
|
|
381
|
+
loader = loader_class(text)
|
|
382
|
+
node = loader.get_single_node()
|
|
383
|
+
if node is not None:
|
|
384
|
+
_record(loader, node, src)
|
|
385
|
+
src.data = loader.construct_document(node)
|
|
386
|
+
except yaml.reader.ReaderError as e:
|
|
387
|
+
raise CVError(f"character U+{e.character:04X} is not allowed in YAML", name,
|
|
388
|
+
*_text_position(text, e.position)) from None
|
|
389
|
+
except yaml.MarkedYAMLError as e:
|
|
390
|
+
mark = e.problem_mark or e.context_mark
|
|
391
|
+
problem = e.problem or e.context or "invalid YAML"
|
|
392
|
+
raise CVError(problem, name, *(_mark(mark) if mark else (None, None))) from None
|
|
393
|
+
except yaml.YAMLError as e:
|
|
394
|
+
raise CVError(f"invalid YAML: {e}", name) from None
|
|
395
|
+
finally:
|
|
396
|
+
if loader is not None:
|
|
397
|
+
loader.dispose()
|
|
398
|
+
return src
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _text_position(text: str, index: int) -> tuple[int, int]:
|
|
402
|
+
before = re.sub("\r\n", "\n", text[:index])
|
|
403
|
+
lines = re.split("[\n\r\x85\u2028\u2029]", before)
|
|
404
|
+
return len(lines), len(lines[-1]) + 1
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def resolves_to_string(value: str) -> bool:
|
|
408
|
+
"""True if ``value`` as a plain scalar is a string in YAML 1.1 and 1.2."""
|
|
409
|
+
for resolver in (yaml.SafeLoader, _CoreLoader):
|
|
410
|
+
tag = resolver.resolve(resolver, ScalarNode, value, (True, False)) # type: ignore[arg-type]
|
|
411
|
+
if tag != "tag:yaml.org,2002:str":
|
|
412
|
+
return False
|
|
413
|
+
return True
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def load_file(path: str | Path) -> Source:
|
|
417
|
+
path = Path(path)
|
|
418
|
+
try:
|
|
419
|
+
text = path.read_text(encoding="utf-8")
|
|
420
|
+
except OSError as e:
|
|
421
|
+
raise CVError(f"cannot read file: {e.strerror}", str(path)) from None
|
|
422
|
+
except UnicodeDecodeError:
|
|
423
|
+
raise CVError("file is not valid UTF-8", str(path)) from None
|
|
424
|
+
return load_text(text, str(path), path.resolve().parent)
|