Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ All notable changes to this project are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## 0.6.2 — 2026-10-07

A patch release of the converter's writing: a link written into GLPI opens in
a new window. Reading is unchanged. `easyvista-python-client` 0.4.2 makes the
same change, so the two converters still write the same HTML.

### Changed

- **Every link `to_transport` writes carries `target="_blank"
rel="noopener noreferrer"`**, as a link written in GLPI's own editor carries
`target="_blank"`. Without it a link opens in place of the page showing it.
Write models render through the same function, so a followup, task,
solution or ticket body written through them carries it too. Reading
ignores both attributes, so `from_transport(to_transport(m))` is unchanged
for every link.

## 0.6.1 — 2026-10-02

The reader now gives the same Markdown as `easyvista-python-client` 0.4.1,
Expand Down
4 changes: 3 additions & 1 deletion docs/user_guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1838,7 +1838,9 @@ becomes:

Writing, your Markdown is rendered by cmark-gfm. A newline is a line break,
GFM tables work, and raw HTML passes through, so put a placeholder such as
``<login>`` in backticks. A write model keeps your Markdown as written,
``<login>`` in backticks. A link opens in a new window, as a link written in
GLPI's editor does: each ``<a href>`` the renderer writes carries
``target="_blank" rel="noopener noreferrer"``, which reading ignores. A write model keeps your Markdown as written,
stripped at both ends, unless it starts with ``<`` and holds an HTML
element anywhere, in which case it is read as HTML. Stripping unindents the
first line of a body that opens with an indented code block, which then
Expand Down
2 changes: 1 addition & 1 deletion glpi_python_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@
date_window,
)

__version__ = "0.6.1"
__version__ = "0.6.2"

__all__ = [
"AsyncGlpiClient",
Expand Down
8 changes: 4 additions & 4 deletions glpi_python_client/_async/_testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,10 @@ async def _delete(
)
return FakeResponse(status_code=self._delete_status, payload={})

client._get_request = _get # type: ignore[method-assign, assignment]
client._post_request = _post # type: ignore[method-assign, assignment]
client._update_request = _patch # type: ignore[method-assign, assignment]
client._delete_request = _delete # type: ignore[method-assign, assignment]
client._get_request = _get # type: ignore[method-assign, assignment, unused-ignore]
client._post_request = _post # type: ignore[method-assign, assignment, unused-ignore]
client._update_request = _patch # type: ignore[method-assign, assignment, unused-ignore]
client._delete_request = _delete # type: ignore[method-assign, assignment, unused-ignore]


class FailingTransportRecorder(TransportRecorder):
Expand Down
8 changes: 4 additions & 4 deletions glpi_python_client/_sync/_testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,10 @@ def _delete(
)
return FakeResponse(status_code=self._delete_status, payload={})

client._get_request = _get # type: ignore[method-assign, assignment]
client._post_request = _post # type: ignore[method-assign, assignment]
client._update_request = _patch # type: ignore[method-assign, assignment]
client._delete_request = _delete # type: ignore[method-assign, assignment]
client._get_request = _get # type: ignore[method-assign, assignment, unused-ignore]
client._post_request = _post # type: ignore[method-assign, assignment, unused-ignore]
client._update_request = _patch # type: ignore[method-assign, assignment, unused-ignore]
client._delete_request = _delete # type: ignore[method-assign, assignment, unused-ignore]


class FailingTransportRecorder(TransportRecorder):
Expand Down
17 changes: 16 additions & 1 deletion glpi_python_client/content/conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -715,12 +715,27 @@ def html_to_markdown(html: str) -> str:
return str(_FORMATTER.render(_CONVERTER.convert_soup(soup))).strip()


#: A link as cmark-gfm writes one: an ``href``, an optional ``title``, nothing else.
_RENDERED_LINK = re.compile(r'<a href="[^"]*"(?: title="[^"]*")?>')

#: What GLPI's own editor writes on a link, so that it opens in a new window
#: rather than in place of the page showing it. ``noreferrer`` too, as
#: ``easyvista-python-client``'s twin writes, so the two render alike.
_NEW_WINDOW = ' target="_blank" rel="noopener noreferrer"'


def markdown_to_html(markdown: str) -> str:
"""Render Markdown as HTML: CommonMark with GFM tables, through cmark-gfm."""
"""Render Markdown as HTML: CommonMark with GFM tables, through cmark-gfm.

Every link opens in a new window, the way a link written in GLPI's editor
does: ``target="_blank" rel="noopener noreferrer"`` is added to each link
cmark-gfm writes. Reading ignores both attributes.
"""

html: str = cmarkgfm.markdown_to_html_with_extensions(
markdown, options=_RENDER_OPTIONS, extensions=["table"]
)
html = _RENDERED_LINK.sub(lambda link: link.group(0)[:-1] + _NEW_WINDOW + ">", html)
return html.strip()


Expand Down
66 changes: 64 additions & 2 deletions glpi_python_client/content/tests/test_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
read = GlpiContentConverter.from_transport
render = GlpiContentConverter.to_transport

#: What every rendered link carries: GLPI's editor writes it on a link it makes.
NEW_WINDOW = ' target="_blank" rel="noopener noreferrer"'


def test_content_is_markdown_in_python_and_html_for_glpi() -> None:
assert read("<p>The printer is <strong>offline</strong>.</p>") == (
Expand Down Expand Up @@ -217,12 +220,12 @@ def test_deeply_nested_markdown_renders() -> None:
),
pytest.param(
"[x](javascript:alert(1))",
'<p><a href="javascript:alert(1)">x</a></p>',
f'<p><a href="javascript:alert(1)"{NEW_WINDOW}>x</a></p>',
id="link-target",
),
pytest.param( # python-markdown, before 0.6.0, left this as raw markup
"<javascript:alert(1)>",
'<p><a href="javascript:alert(1)">javascript:alert(1)</a></p>',
f'<p><a href="javascript:alert(1)"{NEW_WINDOW}>javascript:alert(1)</a></p>',
id="autolink",
),
],
Expand All @@ -242,3 +245,62 @@ def test_markup_a_body_displays_as_text_stays_text_both_ways() -> None:

assert markdown == "\\<script>"
assert render(markdown) == "<p>&lt;script&gt;</p>"


# A link opens in a new window, as a link written in GLPI's editor does
# (``target="_blank"``), rather than in place of the page showing it. The same
# attributes as ``easyvista-python-client`` 0.4.2 writes, so the twins agree.


@pytest.mark.parametrize(
("markdown", "html"),
[
pytest.param(
"[voir](https://example.org/p)",
f'<p><a href="https://example.org/p"{NEW_WINDOW}>voir</a></p>',
id="link",
),
pytest.param(
'[voir](https://example.org/p "le titre")',
f'<p><a href="https://example.org/p" title="le titre"{NEW_WINDOW}>'
"voir</a></p>",
id="titled",
),
pytest.param(
"<https://example.org/p>",
f'<p><a href="https://example.org/p"{NEW_WINDOW}>https://example.org/p</a></p>',
id="autolink",
),
pytest.param(
"[![a](https://example.org/i.png)](https://example.org/p)",
f'<p><a href="https://example.org/p"{NEW_WINDOW}>'
'<img src="https://example.org/i.png" alt="a" /></a></p>',
id="image-link",
),
],
)
def test_every_link_written_opens_in_a_new_window(markdown: str, html: str) -> None:
assert render(markdown) == html


def test_a_link_shown_as_code_is_text_and_gains_nothing() -> None:
assert render('`<a href="https://example.org">x</a>`') == (
"<p><code>&lt;a href=&quot;https://example.org&quot;&gt;x&lt;/a&gt;</code></p>"
)


@pytest.mark.parametrize(
"markdown",
[
"[voir](https://example.org/p)",
'[voir](https://example.org/p "le titre")',
"<https://example.org/p>",
"[![a](https://example.org/i.png)](https://example.org/p)",
],
ids=["link", "titled", "autolink", "image-link"],
)
def test_a_link_written_to_open_in_a_new_window_reads_back_as_written(
markdown: str,
) -> None:
# Reading ignores ``target`` and ``rel``, so a round trip stays exact.
assert read(render(markdown)) == markdown
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ exclude = [

[project]
name = "glpi-python-client"
version = "0.6.1"
version = "0.6.2"
description = "A typed Python client for GLPI ITSM APIs."
readme = "README.md"
requires-python = ">=3.11"
Expand Down
2 changes: 1 addition & 1 deletion skills/glpi-asset-workflow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ license: MIT
compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read or write assets."
metadata:
package: glpi-python-client
version: "0.6.1"
version: "0.6.2"
---

# GLPI Asset Workflow
Expand Down
2 changes: 1 addition & 1 deletion skills/glpi-client-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ license: MIT
compatibility: "Requires Python 3.11+, glpi-python-client, network access to a GLPI v2 API, and valid GLPI credentials."
metadata:
package: glpi-python-client
version: "0.6.1"
version: "0.6.2"
---

# GLPI Client Setup
Expand Down
2 changes: 1 addition & 1 deletion skills/glpi-contract-workflow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ license: MIT
compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read or write contracts."
metadata:
package: glpi-python-client
version: "0.6.1"
version: "0.6.2"
---

# GLPI Contract Workflow
Expand Down
2 changes: 1 addition & 1 deletion skills/glpi-document-workflow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ license: MIT
compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and v1 credentials configured on the client for binary uploads."
metadata:
package: glpi-python-client
version: "0.6.1"
version: "0.6.2"
---

# GLPI Document Workflow
Expand Down
2 changes: 1 addition & 1 deletion skills/glpi-knowledge-base/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ license: MIT
compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and — for category writes only — a legacy v1 session (v1_base_url + v1_user_token)."
metadata:
package: glpi-python-client
version: "0.6.1"
version: "0.6.2"
---

# GLPI Knowledge Base
Expand Down
2 changes: 1 addition & 1 deletion skills/glpi-plugin-fields/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ license: MIT
compatibility: "Requires Python 3.11+, glpi-python-client, the GLPI Fields plugin installed server-side, and a legacy v1 session (v1_base_url + v1_user_token) — every method in this family goes over the v1 API."
metadata:
package: glpi-python-client
version: "0.6.1"
version: "0.6.2"
---

# GLPI Plugin Fields
Expand Down
2 changes: 1 addition & 1 deletion skills/glpi-reporting-and-context/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ license: MIT
compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read tickets, tasks, users, entities, and timeline records."
metadata:
package: glpi-python-client
version: "0.6.1"
version: "0.6.2"
---

# GLPI Reporting And Context
Expand Down
2 changes: 1 addition & 1 deletion skills/glpi-team-members/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ license: MIT
compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to manage ticket teams."
metadata:
package: glpi-python-client
version: "0.6.1"
version: "0.6.2"
---

# GLPI Team Members
Expand Down
2 changes: 1 addition & 1 deletion skills/glpi-ticket-timeline/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ license: MIT
compatibility: "Requires Python 3.11+, glpi-python-client, and network access to the GLPI v2 API."
metadata:
package: glpi-python-client
version: "0.6.1"
version: "0.6.2"
---

# GLPI Ticket Timeline
Expand Down
2 changes: 1 addition & 1 deletion skills/glpi-ticket-workflow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ license: MIT
compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials accepted by GlpiClient."
metadata:
package: glpi-python-client
version: "0.6.1"
version: "0.6.2"
---

# GLPI Ticket Workflow
Expand Down
2 changes: 1 addition & 1 deletion skills/glpi-user-location-provisioning/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ license: MIT
compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read or write users, locations, and entities."
metadata:
package: glpi-python-client
version: "0.6.1"
version: "0.6.2"
---

# GLPI User, Location, And Entity Provisioning
Expand Down
Loading