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.
@@ -131,7 +131,7 @@ from glpi_python_client.rsql import (
131
131
  date_window,
132
132
  )
133
133
 
134
- __version__ = "0.6.0"
134
+ __version__ = "0.6.2"
135
135
 
136
136
  __all__ = [
137
137
  "AsyncGlpiClient",
@@ -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):
@@ -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, ``markdown`` outbound), and a parser fault
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
- Rendering the Markdown a read gives displays what GLPI displayed, and
15
- reading that back gives the same Markdown. The glue below covers what the
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 is read as its text
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
- _EDGES = re.compile(r"((?:\\\n|\s)*)(.*?)((?:\\\n|\s)*)", re.DOTALL)
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. Inside a cell, a table's parts become ``span`` and its cells
176
- ``glpi-cell``, which :class:`_Converter` writes as spaced inline text.
177
- Renaming in one walk keeps it linear however deeply tables nest.
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
- counted.append(is_cell)
195
- cells += is_cell
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 != "br":
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 "pre" in parent_tags:
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 = str(self._inherited("convert_code")(el, text, parent_tags))
370
- if "td" in parent_tags or "th" in parent_tags:
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 "![^" as "!" and a link
418
543
  src = _destination(str(el.get("src") or ""))
419
544
  return f"![{alt}]({src}{_title(str(el.get('title') 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 verbatim unless it starts
615
- with a real HTML tag, so Markdown carrying an inline ``<br>`` or
616
- ``<kbd>`` is still Markdown.
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 is read as its text instead, so this is a
628
- backstop.
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("<", "&lt;")
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 verbatim unless it starts with an HTML tag -- reading it as literal
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.0
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>=3.0
31
- Requires-Dist: markdownify>=1.2
32
- Requires-Dist: mdformat-tables>=1.0
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=5ea1QJ2u_7suINrrs6xpXzkDM-stCYbsfZI9Kdlnirw,5538
2
- glpi_python_client/_errors.py,sha256=Nd3qciU9WMXiHIZmq4tMq37P_bSlq8lhmQqrjUBOkVk,7133
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=AJSonZXqRZ1MIqO46HytwjFd33vWhAbagFGcxytv4wA,5286
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=1lPzYl53HWkSXpGpFKWzf8bhrtervBSHXMVR78b0oGM,5242
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=3tF6fvw-yb8UJiJ0qBh1e0GqEBXs780wRCKl8nyNgN0,24750
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=OMdcJ4J9-FnRM-EF_WtnVyIM4OzRY_4fJZZxA0hDCIM,12268
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.0.dist-info/METADATA,sha256=wfWloBVKKI5itijSk2BjxPEzwhSi6SxOToUmAz9yoTU,8219
139
- glpi_python_client-0.6.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
140
- glpi_python_client-0.6.0.dist-info/licenses/LICENSE,sha256=nL8re-TIKkxCoL7OkSBja6EB4NsQR1HoOJgxv_MEhtc,1088
141
- glpi_python_client-0.6.0.dist-info/RECORD,,
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,,