glpi-python-client 0.6.0__py3-none-any.whl → 0.6.2__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.
- glpi_python_client/__init__.py +1 -1
- glpi_python_client/_async/_testing.py +4 -4
- glpi_python_client/_errors.py +2 -2
- glpi_python_client/_sync/_testing.py +4 -4
- glpi_python_client/content/conversion.py +195 -23
- glpi_python_client/models/api_schema/_content.py +13 -2
- {glpi_python_client-0.6.0.dist-info → glpi_python_client-0.6.2.dist-info}/METADATA +4 -4
- {glpi_python_client-0.6.0.dist-info → glpi_python_client-0.6.2.dist-info}/RECORD +10 -10
- {glpi_python_client-0.6.0.dist-info → glpi_python_client-0.6.2.dist-info}/WHEEL +0 -0
- {glpi_python_client-0.6.0.dist-info → glpi_python_client-0.6.2.dist-info}/licenses/LICENSE +0 -0
glpi_python_client/__init__.py
CHANGED
|
@@ -127,10 +127,10 @@ class TransportRecorder:
|
|
|
127
127
|
)
|
|
128
128
|
return FakeResponse(status_code=self._delete_status, payload={})
|
|
129
129
|
|
|
130
|
-
client._get_request = _get # type: ignore[method-assign, assignment]
|
|
131
|
-
client._post_request = _post # type: ignore[method-assign, assignment]
|
|
132
|
-
client._update_request = _patch # type: ignore[method-assign, assignment]
|
|
133
|
-
client._delete_request = _delete # type: ignore[method-assign, assignment]
|
|
130
|
+
client._get_request = _get # type: ignore[method-assign, assignment, unused-ignore]
|
|
131
|
+
client._post_request = _post # type: ignore[method-assign, assignment, unused-ignore]
|
|
132
|
+
client._update_request = _patch # type: ignore[method-assign, assignment, unused-ignore]
|
|
133
|
+
client._delete_request = _delete # type: ignore[method-assign, assignment, unused-ignore]
|
|
134
134
|
|
|
135
135
|
|
|
136
136
|
class FailingTransportRecorder(TransportRecorder):
|
glpi_python_client/_errors.py
CHANGED
|
@@ -149,8 +149,8 @@ class GlpiContentError(GlpiError):
|
|
|
149
149
|
|
|
150
150
|
This exists so that no failure of the content layer escapes the
|
|
151
151
|
package's taxonomy. The conversion runs third-party parsers
|
|
152
|
-
(``markdownify`` inbound, ``
|
|
153
|
-
used to reach the caller as a bare builtin — most visibly a
|
|
152
|
+
(``markdownify`` and ``mdformat`` inbound, ``cmark-gfm`` outbound), and a
|
|
153
|
+
parser fault used to reach the caller as a bare builtin — most visibly a
|
|
154
154
|
``RecursionError``, which ``except GlpiError`` does not catch and which
|
|
155
155
|
a caller reading a ticket has no reason to expect from
|
|
156
156
|
``get_ticket``. Deeply nested HTML is caught and answered with the
|
|
@@ -127,10 +127,10 @@ class TransportRecorder:
|
|
|
127
127
|
)
|
|
128
128
|
return FakeResponse(status_code=self._delete_status, payload={})
|
|
129
129
|
|
|
130
|
-
client._get_request = _get # type: ignore[method-assign, assignment]
|
|
131
|
-
client._post_request = _post # type: ignore[method-assign, assignment]
|
|
132
|
-
client._update_request = _patch # type: ignore[method-assign, assignment]
|
|
133
|
-
client._delete_request = _delete # type: ignore[method-assign, assignment]
|
|
130
|
+
client._get_request = _get # type: ignore[method-assign, assignment, unused-ignore]
|
|
131
|
+
client._post_request = _post # type: ignore[method-assign, assignment, unused-ignore]
|
|
132
|
+
client._update_request = _patch # type: ignore[method-assign, assignment, unused-ignore]
|
|
133
|
+
client._delete_request = _delete # type: ignore[method-assign, assignment, unused-ignore]
|
|
134
134
|
|
|
135
135
|
|
|
136
136
|
class FailingTransportRecorder(TransportRecorder):
|
|
@@ -11,14 +11,27 @@ GLPI stores rich text as HTML; the package's surface is Markdown.
|
|
|
11
11
|
renders CommonMark with GFM tables; a newline is a line break and raw
|
|
12
12
|
HTML passes through.
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
reading that back gives the same Markdown.
|
|
14
|
+
The aim is that rendering the Markdown a read gives displays what GLPI
|
|
15
|
+
displayed, and that reading that back gives the same Markdown. Some
|
|
16
|
+
synthetic shapes still lose words or the fixed point; the user guide's
|
|
17
|
+
"Rich-text content" section lists them. The glue below covers what the
|
|
16
18
|
three libraries leave out: plain text, line breaks a browser does not
|
|
17
19
|
show, bold and italic CommonMark would not close, link targets, and an
|
|
18
20
|
mdformat set up without its nesting cap and with its quadratic lookups made
|
|
19
|
-
linear. A body nested too deeply for the stack
|
|
21
|
+
linear. A body nested too deeply for the stack, or on which the
|
|
22
|
+
conversion raises ``ValueError`` -- markdownify does for a ``colspan`` or
|
|
23
|
+
``start`` it cannot read as a number -- is read as its text
|
|
20
24
|
(:func:`_text_of`); anything else that fails raises
|
|
21
25
|
:class:`~glpi_python_client.GlpiContentError`.
|
|
26
|
+
|
|
27
|
+
The reader is ``easyvista-python-client`` 0.4.1's, helper for helper: that
|
|
28
|
+
package ported this module at commit ``917f030`` and added fifteen fixes,
|
|
29
|
+
which 0.6.1 ports back with the correction to fix 11 both packages now
|
|
30
|
+
carry (a ``<u>``, ``<mark>`` or ``<ins>`` round a block is dropped). The
|
|
31
|
+
two modules differ only in names, messages, docstrings and that package's
|
|
32
|
+
optional-dependency import guard, and should move together: the hard part
|
|
33
|
+
of both is the behaviour of the same libraries, not anything either ITSM
|
|
34
|
+
does.
|
|
22
35
|
"""
|
|
23
36
|
|
|
24
37
|
from __future__ import annotations
|
|
@@ -44,6 +57,7 @@ from mdformat.renderer import (
|
|
|
44
57
|
RenderContext,
|
|
45
58
|
RenderTreeNode,
|
|
46
59
|
)
|
|
60
|
+
from mdformat.renderer.typing import Postprocess
|
|
47
61
|
|
|
48
62
|
from glpi_python_client._errors import GlpiContentError
|
|
49
63
|
|
|
@@ -90,9 +104,19 @@ _HIDDEN = ["head", "script", "style", "template", "title"]
|
|
|
90
104
|
#: carry the characters displayed on either side of one (:func:`_note_sides`).
|
|
91
105
|
_EMPHASIS = frozenset({"b", "strong", "em", "i"})
|
|
92
106
|
_BEFORE, _AFTER = "data-glpi-before", "data-glpi-after"
|
|
107
|
+
_NUMBER = "data-glpi-number" # an ordered item's number (_Converter.convert_li)
|
|
108
|
+
|
|
109
|
+
#: The elements kept as raw tags (:meth:`_Converter.convert_u`), and the
|
|
110
|
+
#: attribute marking one that holds a block (:func:`_note_blocks`).
|
|
111
|
+
_RAW_INLINE = frozenset({"u", "mark", "ins"})
|
|
112
|
+
_HOLDS_BLOCK = "data-glpi-block"
|
|
93
113
|
|
|
94
114
|
#: Text split into leading line breaks and spaces, content, trailing ones.
|
|
95
|
-
|
|
115
|
+
#: The content ends on its last character that is neither, found greedily:
|
|
116
|
+
#: a lazy ``.*?`` rescanned the run after it at every step, quadratic.
|
|
117
|
+
_EDGES = re.compile(
|
|
118
|
+
r"((?:\\\n|\s)*)((?:.*(?:[^\\\s]|\\(?!\n)))?)((?:\\\n|\s)*)", re.DOTALL
|
|
119
|
+
)
|
|
96
120
|
|
|
97
121
|
#: A URL that is its own CommonMark autolink and that markdown-it leaves as it
|
|
98
122
|
#: is: printable ASCII it does not percent-encode.
|
|
@@ -166,15 +190,19 @@ _TABLE_PARTS = frozenset(
|
|
|
166
190
|
{"table", "thead", "tbody", "tfoot", "tr", "caption", "colgroup", "col"}
|
|
167
191
|
)
|
|
168
192
|
|
|
193
|
+
#: What Markdown writes on one line: a table inside one cannot be a table.
|
|
194
|
+
_ONE_LINE = frozenset({"td", "th", "a", "h1", "h2", "h3", "h4", "h5", "h6"})
|
|
195
|
+
|
|
169
196
|
|
|
170
197
|
def _flatten_nested_tables(root: Tag) -> None:
|
|
171
|
-
"""Write a table that sits inside a cell as its cells' text.
|
|
198
|
+
"""Write a table that sits inside a cell, a heading or a link as its cells' text.
|
|
172
199
|
|
|
173
200
|
A GFM cell holds one line, so a nested table -- the usual layout of an
|
|
174
201
|
e-mail signature -- written as a table split the outer row and lost
|
|
175
|
-
every word
|
|
176
|
-
``glpi-cell``, which
|
|
177
|
-
|
|
202
|
+
every word; in a heading or a link its pipes became text. Inside one, a
|
|
203
|
+
table's parts become ``span`` and its cells ``glpi-cell``, which
|
|
204
|
+
:class:`_Converter` writes as spaced inline text. Renaming in one walk
|
|
205
|
+
keeps it linear however deeply tables nest.
|
|
178
206
|
"""
|
|
179
207
|
|
|
180
208
|
cells = 0
|
|
@@ -191,8 +219,9 @@ def _flatten_nested_tables(root: Tag) -> None:
|
|
|
191
219
|
node.name = "glpi-cell" if is_cell else "span"
|
|
192
220
|
counted.append(False)
|
|
193
221
|
continue
|
|
194
|
-
|
|
195
|
-
|
|
222
|
+
holds = node.name in _ONE_LINE and (node.name != "a" or bool(node.get("href")))
|
|
223
|
+
counted.append(holds)
|
|
224
|
+
cells += holds
|
|
196
225
|
|
|
197
226
|
|
|
198
227
|
def _walk(root: Tag) -> Iterator[tuple[PageElement, bool]]:
|
|
@@ -245,7 +274,8 @@ def _note_sides(root: Tag) -> None:
|
|
|
245
274
|
|
|
246
275
|
One pass: ``last`` is the last character shown so far on the current
|
|
247
276
|
line, and an element that has closed waits for the next one. A line edge
|
|
248
|
-
counts as a space, as it does for CommonMark
|
|
277
|
+
counts as a space, as it does for CommonMark, and so does the edge of a
|
|
278
|
+
flattened cell, which :class:`_Converter` spaces.
|
|
249
279
|
"""
|
|
250
280
|
|
|
251
281
|
last = " "
|
|
@@ -258,7 +288,7 @@ def _note_sides(root: Tag) -> None:
|
|
|
258
288
|
else:
|
|
259
289
|
waiting.append(node)
|
|
260
290
|
continue
|
|
261
|
-
if node.name not in _BLOCKS and node.name
|
|
291
|
+
if node.name not in _BLOCKS and node.name not in ("br", "glpi-cell"):
|
|
262
292
|
continue
|
|
263
293
|
first = last = " "
|
|
264
294
|
else:
|
|
@@ -273,6 +303,33 @@ def _note_sides(root: Tag) -> None:
|
|
|
273
303
|
element[_AFTER] = " "
|
|
274
304
|
|
|
275
305
|
|
|
306
|
+
def _note_blocks(root: Tag) -> None:
|
|
307
|
+
"""Mark each ``<u>``, ``<mark>`` or ``<ins>`` that holds a block.
|
|
308
|
+
|
|
309
|
+
Markdown has no inline tag around blocks: such a tag wrapped round them
|
|
310
|
+
showed a table, a list or a heading as its Markdown source, and round a
|
|
311
|
+
``<pre>`` left a fence open to the end of the body. One walk: a block
|
|
312
|
+
marks the open ones from the innermost out and stops at one already
|
|
313
|
+
marked, whose holders were marked with it, so each is marked once
|
|
314
|
+
however deeply they nest.
|
|
315
|
+
"""
|
|
316
|
+
|
|
317
|
+
holders: list[Tag] = []
|
|
318
|
+
for node, entering in _walk(root):
|
|
319
|
+
if not isinstance(node, Tag):
|
|
320
|
+
continue
|
|
321
|
+
if node.name in _RAW_INLINE:
|
|
322
|
+
if entering:
|
|
323
|
+
holders.append(node)
|
|
324
|
+
else:
|
|
325
|
+
holders.pop()
|
|
326
|
+
elif entering and node.name in _BLOCKS:
|
|
327
|
+
for holder in reversed(holders):
|
|
328
|
+
if holder.has_attr(_HOLDS_BLOCK):
|
|
329
|
+
break
|
|
330
|
+
holder[_HOLDS_BLOCK] = ""
|
|
331
|
+
|
|
332
|
+
|
|
276
333
|
def _punctuation(char: str) -> bool:
|
|
277
334
|
"""Return whether CommonMark counts ``char`` as punctuation."""
|
|
278
335
|
|
|
@@ -316,6 +373,21 @@ class _Converter(MarkdownConverter):
|
|
|
316
373
|
def _inherited(self, name: str) -> Any:
|
|
317
374
|
return getattr(super(), name)
|
|
318
375
|
|
|
376
|
+
def escape(self, text: str, parent_tags: set[str]) -> str:
|
|
377
|
+
"""Escape a text's last ``!`` as well: before a link it makes an image.
|
|
378
|
+
|
|
379
|
+
A ``!`` meets a ``[`` only there, markdownify escaping a ``[`` in text;
|
|
380
|
+
mdformat keeps that escape before a link and drops it anywhere else.
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
text = str(self._inherited("escape")(text, parent_tags))
|
|
384
|
+
return text[:-1] + "\\!" if text.endswith("!") else text
|
|
385
|
+
|
|
386
|
+
def process_tag(self, node: Any, parent_tags: Any = None) -> str:
|
|
387
|
+
if node.name == "glpi-cell": # one line, as a cell is: its blocks are inline
|
|
388
|
+
parent_tags = {*(parent_tags or ()), "_inline"}
|
|
389
|
+
return str(self._inherited("process_tag")(node, parent_tags))
|
|
390
|
+
|
|
319
391
|
def _markup(
|
|
320
392
|
self, el: Tag, text: str, parent_tags: set[str], markers: str, tag: str
|
|
321
393
|
) -> str:
|
|
@@ -353,26 +425,56 @@ class _Converter(MarkdownConverter):
|
|
|
353
425
|
convert_del = convert_s
|
|
354
426
|
convert_strike = convert_s
|
|
355
427
|
|
|
428
|
+
def convert_u(self, el: Tag, text: str, parent_tags: set[str]) -> str:
|
|
429
|
+
"""Underlined or highlighted text: CommonMark has neither, so raw HTML.
|
|
430
|
+
|
|
431
|
+
Round a block the tag is dropped and the blocks kept, except on the
|
|
432
|
+
one line of a cell or a heading, where the blocks are inline text.
|
|
433
|
+
"""
|
|
434
|
+
|
|
435
|
+
if el.has_attr(_HOLDS_BLOCK) and "_inline" not in parent_tags:
|
|
436
|
+
return text
|
|
437
|
+
return self._markup(el, text, parent_tags, "", el.name)
|
|
438
|
+
|
|
439
|
+
convert_mark = convert_ins = convert_u
|
|
440
|
+
|
|
441
|
+
def convert_center(self, el: Tag, text: str, parent_tags: set[str]) -> str:
|
|
442
|
+
"""``<center>``, obsolete, is a block, as ``<div>`` is."""
|
|
443
|
+
|
|
444
|
+
if "pre" in parent_tags:
|
|
445
|
+
return text
|
|
446
|
+
return str(self._inherited("convert_div")(el, text, parent_tags))
|
|
447
|
+
|
|
356
448
|
def convert_glpi_cell(self, el: Tag, text: str, parent_tags: set[str]) -> str:
|
|
357
449
|
"""A cell of a table nested in a cell: its text, set apart by spaces."""
|
|
358
450
|
|
|
359
451
|
return f" {text.strip()} "
|
|
360
452
|
|
|
361
453
|
def convert_br(self, el: Tag, text: str, parent_tags: set[str]) -> str:
|
|
362
|
-
if "
|
|
363
|
-
return "\n"
|
|
454
|
+
if "_noformat" in parent_tags:
|
|
455
|
+
return "\n" # in <pre>, or in inline code, which convert_code splits
|
|
364
456
|
if "td" in parent_tags or "th" in parent_tags:
|
|
365
457
|
return "<br>" # a cell is one line of Markdown; cmark-gfm passes the tag
|
|
366
458
|
return str(self._inherited("convert_br")(el, text, parent_tags))
|
|
367
459
|
|
|
368
460
|
def convert_code(self, el: Tag, text: str, parent_tags: set[str]) -> str:
|
|
369
|
-
code
|
|
370
|
-
|
|
461
|
+
"""Inline code, a span per line: a code span cannot hold a line break."""
|
|
462
|
+
|
|
463
|
+
cell = "td" in parent_tags or "th" in parent_tags
|
|
464
|
+
lines = [text] if "_noformat" in parent_tags else text.split("\n")
|
|
465
|
+
join = "<br>" if cell else " " if "_inline" in parent_tags else "\\\n"
|
|
466
|
+
code = join.join(
|
|
467
|
+
str(self._inherited("convert_code")(el, line, parent_tags))
|
|
468
|
+
for line in lines
|
|
469
|
+
)
|
|
470
|
+
if cell:
|
|
371
471
|
code = code.replace(
|
|
372
472
|
"|", "\\|"
|
|
373
473
|
) # GFM splits a row on "|" before it reads code
|
|
374
474
|
return code
|
|
375
475
|
|
|
476
|
+
convert_kbd = convert_samp = convert_code
|
|
477
|
+
|
|
376
478
|
def convert_list(self, el: Tag, text: str, parent_tags: set[str]) -> str:
|
|
377
479
|
"""A list; a nested one ends in a blank line.
|
|
378
480
|
|
|
@@ -385,6 +487,27 @@ class _Converter(MarkdownConverter):
|
|
|
385
487
|
convert_ul = convert_list
|
|
386
488
|
convert_ol = convert_list
|
|
387
489
|
|
|
490
|
+
def convert_li(self, el: Tag, text: str, parent_tags: set[str]) -> str:
|
|
491
|
+
"""An item; an ordered one numbered one past the item before it.
|
|
492
|
+
|
|
493
|
+
markdownify counts every item before each one, quadratic in a long
|
|
494
|
+
list, so each item keeps its number for the next. ``isdecimal``,
|
|
495
|
+
where markdownify's ``isnumeric`` let ``int("²")`` raise.
|
|
496
|
+
"""
|
|
497
|
+
|
|
498
|
+
if el.parent is None or el.parent.name != "ol":
|
|
499
|
+
return str(self._inherited("convert_li")(el, text, parent_tags))
|
|
500
|
+
before = el.find_previous_sibling("li")
|
|
501
|
+
start = str(el.parent.get("start") or "")
|
|
502
|
+
first = int(start) if start.isdecimal() else 1
|
|
503
|
+
number = int(str(before[_NUMBER])) + 1 if isinstance(before, Tag) else first
|
|
504
|
+
el[_NUMBER] = str(number)
|
|
505
|
+
if not text.strip():
|
|
506
|
+
return "\n"
|
|
507
|
+
bullet = f"{number}. "
|
|
508
|
+
body = re.sub("^(?=.)", " " * len(bullet), text.strip(), flags=re.M)
|
|
509
|
+
return bullet + body[len(bullet) :] + "\n"
|
|
510
|
+
|
|
388
511
|
def convert_pre(self, el: Tag, text: str, parent_tags: set[str]) -> str:
|
|
389
512
|
"""A fenced block, its fence longer than any backtick run in the code."""
|
|
390
513
|
|
|
@@ -415,6 +538,8 @@ class _Converter(MarkdownConverter):
|
|
|
415
538
|
|
|
416
539
|
alt = " ".join(str(el.get("alt") or "").split())
|
|
417
540
|
alt = str(self._inherited("escape")(alt, parent_tags))
|
|
541
|
+
if alt.startswith("^") and "_noformat" not in parent_tags:
|
|
542
|
+
alt = "\\" + alt # cmark-gfm reads " or ''))})"
|
|
420
545
|
|
|
@@ -473,6 +598,10 @@ def _list_item(node: RenderTreeNode, context: RenderContext) -> str:
|
|
|
473
598
|
#: character no backslash escapes. ``2k - 1`` read as the same ``k`` there.
|
|
474
599
|
_NEEDLESS_DOUBLING = re.compile(r"(?<!\\)((?:\\\\)+)(?=[^!-/:-@\[-`{-~\\\s])")
|
|
475
600
|
|
|
601
|
+
#: The ``<`` mdformat leaves bare after one it escaped: its pattern eats the
|
|
602
|
+
#: next character, so ``<<a@b.c>`` became ``\<<a@b.c>``, an autolink.
|
|
603
|
+
_SECOND_LESS_THAN = re.compile(r"(?<=\\<)<(?=[^ ]|$)")
|
|
604
|
+
|
|
476
605
|
|
|
477
606
|
def _text(node: RenderTreeNode, context: RenderContext) -> str:
|
|
478
607
|
"""Render text as mdformat does, less one backslash where it escapes nothing.
|
|
@@ -481,9 +610,14 @@ def _text(node: RenderTreeNode, context: RenderContext) -> str:
|
|
|
481
610
|
"""
|
|
482
611
|
|
|
483
612
|
text = DEFAULT_RENDERERS["text"](node, context)
|
|
613
|
+
text = _SECOND_LESS_THAN.sub(r"\\<", text)
|
|
484
614
|
return _NEEDLESS_DOUBLING.sub(lambda found: found.group(1)[:-1], text)
|
|
485
615
|
|
|
486
616
|
|
|
617
|
+
#: A paragraph line opening with ``~~~``, which CommonMark reads as a fence.
|
|
618
|
+
_TILDE_FENCE = re.compile("^~~~", re.MULTILINE)
|
|
619
|
+
|
|
620
|
+
|
|
487
621
|
class _Lists:
|
|
488
622
|
"""An mdformat extension: linear list items, short text and rules."""
|
|
489
623
|
|
|
@@ -492,6 +626,13 @@ class _Lists:
|
|
|
492
626
|
"text": _text,
|
|
493
627
|
"hr": lambda node, context: "---",
|
|
494
628
|
}
|
|
629
|
+
#: What mdformat drops: an escape or entity in an image's alt text, which
|
|
630
|
+
#: markdown-it-py 3 leaves a ``text_special`` token mdformat renders as
|
|
631
|
+
#: nothing, and the escape on a paragraph line opening with ``~~~``.
|
|
632
|
+
POSTPROCESSORS: Mapping[str, Postprocess] = {
|
|
633
|
+
"text_special": lambda text, node, context: node.markup,
|
|
634
|
+
"paragraph": lambda text, node, context: _TILDE_FENCE.sub(r"\\~~~", text),
|
|
635
|
+
}
|
|
495
636
|
|
|
496
637
|
|
|
497
638
|
class _Renderer(MDRenderer):
|
|
@@ -570,15 +711,31 @@ def html_to_markdown(html: str) -> str:
|
|
|
570
711
|
_flatten_nested_tables(soup)
|
|
571
712
|
_drop_trailing_breaks(soup)
|
|
572
713
|
_note_sides(soup)
|
|
714
|
+
_note_blocks(soup)
|
|
573
715
|
return str(_FORMATTER.render(_CONVERTER.convert_soup(soup))).strip()
|
|
574
716
|
|
|
575
717
|
|
|
718
|
+
#: A link as cmark-gfm writes one: an ``href``, an optional ``title``, nothing else.
|
|
719
|
+
_RENDERED_LINK = re.compile(r'<a href="[^"]*"(?: title="[^"]*")?>')
|
|
720
|
+
|
|
721
|
+
#: What GLPI's own editor writes on a link, so that it opens in a new window
|
|
722
|
+
#: rather than in place of the page showing it. ``noreferrer`` too, as
|
|
723
|
+
#: ``easyvista-python-client``'s twin writes, so the two render alike.
|
|
724
|
+
_NEW_WINDOW = ' target="_blank" rel="noopener noreferrer"'
|
|
725
|
+
|
|
726
|
+
|
|
576
727
|
def markdown_to_html(markdown: str) -> str:
|
|
577
|
-
"""Render Markdown as HTML: CommonMark with GFM tables, through cmark-gfm.
|
|
728
|
+
"""Render Markdown as HTML: CommonMark with GFM tables, through cmark-gfm.
|
|
729
|
+
|
|
730
|
+
Every link opens in a new window, the way a link written in GLPI's editor
|
|
731
|
+
does: ``target="_blank" rel="noopener noreferrer"`` is added to each link
|
|
732
|
+
cmark-gfm writes. Reading ignores both attributes.
|
|
733
|
+
"""
|
|
578
734
|
|
|
579
735
|
html: str = cmarkgfm.markdown_to_html_with_extensions(
|
|
580
736
|
markdown, options=_RENDER_OPTIONS, extensions=["table"]
|
|
581
737
|
)
|
|
738
|
+
html = _RENDERED_LINK.sub(lambda link: link.group(0)[:-1] + _NEW_WINDOW + ">", html)
|
|
582
739
|
return html.strip()
|
|
583
740
|
|
|
584
741
|
|
|
@@ -611,9 +768,12 @@ class GlpiContentConverter:
|
|
|
611
768
|
read path, reads plain text as GLPI displays it -- literal
|
|
612
769
|
characters, one line per line -- so ``__init__`` comes back
|
|
613
770
|
escaped. ``True`` is the write models' validator: the value is
|
|
614
|
-
the caller's own Markdown and passes
|
|
615
|
-
|
|
616
|
-
``<kbd>`` is still
|
|
771
|
+
the caller's own Markdown and passes through, stripped, unless
|
|
772
|
+
it starts with ``<`` and holds a real HTML element anywhere. So
|
|
773
|
+
Markdown carrying an inline ``<br>`` or ``<kbd>`` is still
|
|
774
|
+
Markdown, but Markdown that opens with an autolink or other
|
|
775
|
+
angle-bracketed text and carries inline HTML further on is read
|
|
776
|
+
as HTML, and loses that autolink.
|
|
617
777
|
|
|
618
778
|
Returns
|
|
619
779
|
-------
|
|
@@ -624,8 +784,15 @@ class GlpiContentConverter:
|
|
|
624
784
|
------
|
|
625
785
|
GlpiContentError
|
|
626
786
|
The value could not be converted. A body nested too deeply for
|
|
627
|
-
the stack left
|
|
628
|
-
|
|
787
|
+
the stack left, or on which the conversion raises
|
|
788
|
+
``ValueError`` (a ``colspan`` or ``start`` markdownify cannot
|
|
789
|
+
read as a number, among others), is read as its text instead,
|
|
790
|
+
so this is a backstop.
|
|
791
|
+
RecursionError
|
|
792
|
+
Only when called from within a few frames of the recursion
|
|
793
|
+
limit, where no stack is left even to report the failure as
|
|
794
|
+
``GlpiContentError`` (the user guide gives the measured
|
|
795
|
+
depths).
|
|
629
796
|
"""
|
|
630
797
|
|
|
631
798
|
content = str(value or "").strip()
|
|
@@ -637,10 +804,15 @@ class GlpiContentConverter:
|
|
|
637
804
|
return content
|
|
638
805
|
if not _looks_like_html(content):
|
|
639
806
|
content = _plain_text_html(content)
|
|
807
|
+
# No "<" after the last ">" can finish a tag; CPython's html.parser before
|
|
808
|
+
# 3.11.14/3.12.12/3.13.6 rescans to the end for each (quadratic).
|
|
809
|
+
head, end, tail = content.rpartition(">")
|
|
810
|
+
content = head + end + tail.replace("<", "<")
|
|
640
811
|
try:
|
|
641
812
|
try:
|
|
642
813
|
return html_to_markdown(content)
|
|
643
|
-
except RecursionError:
|
|
814
|
+
except (RecursionError, ValueError): # ValueError: markdownify's
|
|
815
|
+
# int() of a colspan or start such as "²" or 5,000 digits
|
|
644
816
|
return html_to_markdown(_plain_text_html(_text_of(content)))
|
|
645
817
|
except ParserRejectedMarkup:
|
|
646
818
|
# html.parser gives up on a few malformed declarations; the
|
|
@@ -54,7 +54,8 @@ Note that the inbound converter also runs on **outbound** content: the
|
|
|
54
54
|
``BeforeValidator`` below fires when a caller constructs a ``Post*`` model,
|
|
55
55
|
so caller-authored Markdown passes through it before the serializer renders
|
|
56
56
|
it. It runs with ``plain_text_is_markdown=True``, which keeps the caller's
|
|
57
|
-
Markdown
|
|
57
|
+
Markdown as written, stripped at both ends, unless it starts with ``<`` and
|
|
58
|
+
holds an HTML element -- reading it as literal
|
|
58
59
|
text would escape it, and GLPI would receive literal asterisks.
|
|
59
60
|
|
|
60
61
|
One sharp edge comes with the read side, from ``functools.cached_property``:
|
|
@@ -83,6 +84,7 @@ Rewrite the raw field, or rebuild through ``model_validate``.
|
|
|
83
84
|
|
|
84
85
|
from __future__ import annotations
|
|
85
86
|
|
|
87
|
+
import re
|
|
86
88
|
from collections.abc import Iterator
|
|
87
89
|
from contextlib import contextmanager
|
|
88
90
|
from contextvars import ContextVar
|
|
@@ -159,6 +161,9 @@ _LAST_CONTENT_FAULT: ContextVar[GlpiContentError | None] = ContextVar(
|
|
|
159
161
|
"glpi_last_content_fault", default=None
|
|
160
162
|
)
|
|
161
163
|
|
|
164
|
+
#: A UTF-16 surrogate standing alone, which UTF-8 cannot encode.
|
|
165
|
+
_LONE_SURROGATE = re.compile("[\ud800-\udfff]")
|
|
166
|
+
|
|
162
167
|
|
|
163
168
|
def _to_transport(value: str | None) -> str | None:
|
|
164
169
|
"""Render an outbound Markdown content value as the HTML GLPI expects.
|
|
@@ -167,6 +172,12 @@ def _to_transport(value: str | None) -> str | None:
|
|
|
167
172
|
drop unset fields from request bodies. Empty Markdown is rendered as an
|
|
168
173
|
empty string to stay consistent with the inbound converter behaviour.
|
|
169
174
|
|
|
175
|
+
A lone surrogate is written as U+FFFD, the way CommonMark writes a NUL:
|
|
176
|
+
cmark-gfm renders UTF-8, which cannot carry one, so the whole write
|
|
177
|
+
failed on a single character. Python strings can hold one -- decoding
|
|
178
|
+
with ``surrogateescape``, or ``json.loads`` of an unpaired ``\\ud800``
|
|
179
|
+
escape -- and the rest of the body keeps its Markdown.
|
|
180
|
+
|
|
170
181
|
A failure is recorded in :data:`_LAST_CONTENT_FAULT` before it is
|
|
171
182
|
raised, because raising it is not enough on its own: see
|
|
172
183
|
:func:`restoring_content_faults`.
|
|
@@ -175,7 +186,7 @@ def _to_transport(value: str | None) -> str | None:
|
|
|
175
186
|
if value is None:
|
|
176
187
|
return None
|
|
177
188
|
try:
|
|
178
|
-
return GlpiContentConverter.to_transport(value)
|
|
189
|
+
return GlpiContentConverter.to_transport(_LONE_SURROGATE.sub("\ufffd", value))
|
|
179
190
|
except GlpiContentError as exc:
|
|
180
191
|
_LAST_CONTENT_FAULT.set(exc)
|
|
181
192
|
raise
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: glpi-python-client
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.2
|
|
4
4
|
Summary: A typed Python client for GLPI ITSM APIs.
|
|
5
5
|
Project-URL: Homepage, https://github.com/baraline/glpi_python_client
|
|
6
6
|
Project-URL: Documentation, https://glpi-python-client.readthedocs.io/en/latest/
|
|
@@ -27,9 +27,9 @@ Requires-Dist: beautifulsoup4>=4.15
|
|
|
27
27
|
Requires-Dist: cmarkgfm>=2025.10
|
|
28
28
|
Requires-Dist: httpx>=0.28
|
|
29
29
|
Requires-Dist: lxml>=4.9
|
|
30
|
-
Requires-Dist: markdown-it-py
|
|
31
|
-
Requires-Dist: markdownify
|
|
32
|
-
Requires-Dist: mdformat-tables
|
|
30
|
+
Requires-Dist: markdown-it-py<4,>=3.0
|
|
31
|
+
Requires-Dist: markdownify<1.3,>=1.2.3
|
|
32
|
+
Requires-Dist: mdformat-tables<1.1,>=1.0
|
|
33
33
|
Requires-Dist: mdformat<0.8,>=0.7.22
|
|
34
34
|
Requires-Dist: pydantic>=2.8
|
|
35
35
|
Requires-Dist: sniffio>=1.3
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
glpi_python_client/__init__.py,sha256=
|
|
2
|
-
glpi_python_client/_errors.py,sha256=
|
|
1
|
+
glpi_python_client/__init__.py,sha256=DGjWsC96c-1sfryxVwHThPASrR5sy0IpNiooeAVx5Wk,5538
|
|
2
|
+
glpi_python_client/_errors.py,sha256=Bc9UvlxulbdcGvzZIKXnJCTL78sW78R6EFg50P-1dpY,7151
|
|
3
3
|
glpi_python_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
4
|
glpi_python_client/rsql.py,sha256=3u3lH3_-F1bvHbVzxALXTDPoHyoMATP1f4QPTar_sEU,8974
|
|
5
5
|
glpi_python_client/_async/__init__.py,sha256=dsDxBNsx94R_-8IBrGj99JidRAnuTQ-10iLA4gtRJhs,602
|
|
6
6
|
glpi_python_client/_async/_concurrency.py,sha256=hd0fGFf-lxQNnx6OFRX0pGCfA3jUOw4RzRpWlEfCqII,2688
|
|
7
|
-
glpi_python_client/_async/_testing.py,sha256=
|
|
7
|
+
glpi_python_client/_async/_testing.py,sha256=xznvwVRgBEPE8QA4ilkWs9Wn0bdn1DdTYX8sOtEYrxs,5346
|
|
8
8
|
glpi_python_client/_async/auth/__init__.py,sha256=h9xb1RcWtgHiod5a7ZDCiMaXJjS9C8J4KtwkkuxGykQ,455
|
|
9
9
|
glpi_python_client/_async/auth/_v1_session.py,sha256=3t1OammbfUm9E-ANynrBFryxjx5UbbuZa4QHrH36IkI,18023
|
|
10
10
|
glpi_python_client/_async/auth/auth.py,sha256=1vGG5uBNG8lMjcm20-mgcsH2e0GWKGujQo4k6vck1lE,14694
|
|
@@ -50,7 +50,7 @@ glpi_python_client/_async/clients/custom/_statistics.py,sha256=GFgxHLWWZvQzHxl2b
|
|
|
50
50
|
glpi_python_client/_async/clients/custom/_ticket_context.py,sha256=9E01Rz2K1HBuiQz8x90UIDYzJy1t-7fLlz-k__8srT8,3021
|
|
51
51
|
glpi_python_client/_sync/__init__.py,sha256=dsDxBNsx94R_-8IBrGj99JidRAnuTQ-10iLA4gtRJhs,602
|
|
52
52
|
glpi_python_client/_sync/_concurrency.py,sha256=453f9hXoEOlWoQWDOzn65THBrP8xsRK4yc8jM-BJb_U,1574
|
|
53
|
-
glpi_python_client/_sync/_testing.py,sha256=
|
|
53
|
+
glpi_python_client/_sync/_testing.py,sha256=l_F9uQbY9wByaXBlyNGLy7jfLyN4TYpGicVE59RBudg,5302
|
|
54
54
|
glpi_python_client/_sync/auth/__init__.py,sha256=wgr3wpYpbLMWvcTyH_Yw0Rg9FpLR68OOkaY92E6Vy7k,453
|
|
55
55
|
glpi_python_client/_sync/auth/_v1_session.py,sha256=dlnYA2yi_aWI6wTpfnRD3nzRvnw1lbx-0BP-KD4jJYs,17870
|
|
56
56
|
glpi_python_client/_sync/auth/auth.py,sha256=azWE3bqDswxU-PtO-m_aKb-luSVPRrRXDVuq2aFRz18,14608
|
|
@@ -95,12 +95,12 @@ glpi_python_client/_sync/clients/custom/__init__.py,sha256=sxfGlHVulucP7bacWhUMi
|
|
|
95
95
|
glpi_python_client/_sync/clients/custom/_statistics.py,sha256=xrD5C99ieROJx60iUomBHacQ_49HpgQL4XjnfREUQvs,38616
|
|
96
96
|
glpi_python_client/_sync/clients/custom/_ticket_context.py,sha256=LDUvKNr8VpZonF5bDo8QYYwMEsWmayVU28Ra3GonNmE,3004
|
|
97
97
|
glpi_python_client/content/__init__.py,sha256=3H4IbrNhgFO0BJrc0xLoeXrFHUE1nqOaDlNgi2MtK9k,365
|
|
98
|
-
glpi_python_client/content/conversion.py,sha256=
|
|
98
|
+
glpi_python_client/content/conversion.py,sha256=0IjZ-36WlGMGwo4sHk92bZ3DDsZxVK0YKGCwrFlnRpo,33158
|
|
99
99
|
glpi_python_client/models/__init__.py,sha256=b6DPZtH0CxyBUnVB0x9vP9AMBIjgubDFm_1TOQuCiVA,5362
|
|
100
100
|
glpi_python_client/models/_base.py,sha256=pYg69KHuR5TDED5utr4Knr7HncBnLTxXHJZYf6vEUKA,12183
|
|
101
101
|
glpi_python_client/models/api_schema/__init__.py,sha256=yTxUYih1Xy7NUogzD7UKp2b8HyE4wdeC_FCKS6ZRVVM,1176
|
|
102
102
|
glpi_python_client/models/api_schema/_common.py,sha256=wVtaIkUM9iS3jyDb0bpFopEkP_3vP1ax7D8rwHe9QkI,2004
|
|
103
|
-
glpi_python_client/models/api_schema/_content.py,sha256=
|
|
103
|
+
glpi_python_client/models/api_schema/_content.py,sha256=DlsEmlXIK_0jyXh3j5PgU6vkwch2o0I8UpUkLq2fqYw,12826
|
|
104
104
|
glpi_python_client/models/api_schema/enums.py,sha256=WDdEhnB_-ZggvY0RTnAX6C6bN8extGzpE9An-Nk5QuQ,4834
|
|
105
105
|
glpi_python_client/models/api_schema/administration/__init__.py,sha256=uTr2rEMYq47T5foUMHm1rtsBmEQvdD1EQw5CZOUN38Y,706
|
|
106
106
|
glpi_python_client/models/api_schema/administration/_entity.py,sha256=KdbxMyPXumGZKmmabDIciF3nFS61pLRp5SBOTFa8slk,3297
|
|
@@ -135,7 +135,7 @@ glpi_python_client/models/custom_schema/_ticket_context.py,sha256=jnlfgho-aDbmA9
|
|
|
135
135
|
glpi_python_client/testing/__init__.py,sha256=iLtUyJjyv3fEhy3PN25NxNTbtqDl3SoHLbNium0Z80U,568
|
|
136
136
|
glpi_python_client/testing/fixtures.py,sha256=HKt6MXHgcmLn4L98pSwp-oGlc9C--_vrABNe5G_iBeg,1286
|
|
137
137
|
glpi_python_client/testing/utils.py,sha256=JyO3w2d-0kYdtC1hEZ2Tu_YjnCz8nUQ_Ck05-1EhsX4,4404
|
|
138
|
-
glpi_python_client-0.6.
|
|
139
|
-
glpi_python_client-0.6.
|
|
140
|
-
glpi_python_client-0.6.
|
|
141
|
-
glpi_python_client-0.6.
|
|
138
|
+
glpi_python_client-0.6.2.dist-info/METADATA,sha256=9wihyUuKVM3_SjCpxgML5kJfV09itUlO6VTUoMwxzgw,8234
|
|
139
|
+
glpi_python_client-0.6.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
140
|
+
glpi_python_client-0.6.2.dist-info/licenses/LICENSE,sha256=nL8re-TIKkxCoL7OkSBja6EB4NsQR1HoOJgxv_MEhtc,1088
|
|
141
|
+
glpi_python_client-0.6.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|