easyvista-python-client 0.4.1__tar.gz → 0.4.3__tar.gz

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.
Files changed (77) hide show
  1. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/CHANGELOG.md +48 -1
  2. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/PKG-INFO +1 -1
  3. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/docs/content.rst +59 -0
  4. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/_version.py +1 -1
  5. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/content/__init__.py +6 -2
  6. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/content/conversion.py +250 -22
  7. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/pyproject.toml +1 -1
  8. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/skills/easyvista-asset-workflow/SKILL.md +1 -1
  9. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/skills/easyvista-client-setup/SKILL.md +1 -1
  10. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/skills/easyvista-directory/SKILL.md +1 -1
  11. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/skills/easyvista-document-workflow/SKILL.md +1 -1
  12. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/skills/easyvista-instance-discovery/SKILL.md +1 -1
  13. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/skills/easyvista-reporting-and-context/SKILL.md +1 -1
  14. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/skills/easyvista-search-syntax/SKILL.md +1 -1
  15. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/skills/easyvista-ticket-actions/SKILL.md +1 -1
  16. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/skills/easyvista-ticket-workflow/SKILL.md +1 -1
  17. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/.gitignore +0 -0
  18. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/.pre-commit-config.yaml +0 -0
  19. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/.readthedocs.yaml +0 -0
  20. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/CONTRIBUTING.md +0 -0
  21. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/LICENSE +0 -0
  22. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/README.md +0 -0
  23. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/docs/_static/.gitkeep +0 -0
  24. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/docs/_templates/.gitkeep +0 -0
  25. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/docs/api_reference.rst +0 -0
  26. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/docs/conf.py +0 -0
  27. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/docs/development.rst +0 -0
  28. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/docs/index.rst +0 -0
  29. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/docs/installation.rst +0 -0
  30. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/docs/publishing.rst +0 -0
  31. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/docs/sponsoring.rst +0 -0
  32. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/docs/user_guide.rst +0 -0
  33. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/docs/vendor-api-reference.md +0 -0
  34. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/__init__.py +0 -0
  35. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/_async/__init__.py +0 -0
  36. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/_async/_concurrency.py +0 -0
  37. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/_async/_transport.py +0 -0
  38. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/_async/client.py +0 -0
  39. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/_fields.py +0 -0
  40. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/_html.py +0 -0
  41. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/_sync/__init__.py +0 -0
  42. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/_sync/_concurrency.py +0 -0
  43. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/_sync/_transport.py +0 -0
  44. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/_sync/client.py +0 -0
  45. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/_transport.py +0 -0
  46. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/config.py +0 -0
  47. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/context.py +0 -0
  48. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/directory.py +0 -0
  49. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/discovery.py +0 -0
  50. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/exceptions.py +0 -0
  51. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/field_model.py +0 -0
  52. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/filters.py +0 -0
  53. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/models/__init__.py +0 -0
  54. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/models/action.py +0 -0
  55. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/models/asset.py +0 -0
  56. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/models/common.py +0 -0
  57. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/models/department.py +0 -0
  58. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/models/document.py +0 -0
  59. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/models/employee.py +0 -0
  60. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/models/generic.py +0 -0
  61. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/models/request.py +0 -0
  62. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/pagination.py +0 -0
  63. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/py.typed +0 -0
  64. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/references.py +0 -0
  65. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/reporting.py +0 -0
  66. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/resources/__init__.py +0 -0
  67. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/resources/actions.py +0 -0
  68. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/resources/assets.py +0 -0
  69. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/resources/departments.py +0 -0
  70. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/resources/descriptor.py +0 -0
  71. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/resources/discovery.py +0 -0
  72. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/resources/documents.py +0 -0
  73. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/resources/employees.py +0 -0
  74. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/resources/requests.py +0 -0
  75. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/timestamps.py +0 -0
  76. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/easyvista_python_client/workflow.py +0 -0
  77. {easyvista_python_client-0.4.1 → easyvista_python_client-0.4.3}/skills/README.md +0 -0
@@ -15,6 +15,51 @@ is the error. Tags carry no `v` prefix.
15
15
 
16
16
  ## [Unreleased]
17
17
 
18
+ ## [0.4.3] - 2026-10-08
19
+
20
+ A patch release of the content reader: a caller can recognise the images a
21
+ memo embeds and decide, link by link, what a read writes. Without the new
22
+ argument a read is unchanged, byte for byte; writing is unchanged, and so is
23
+ everything outside `easyvista_python_client.content`.
24
+
25
+ ### Added
26
+
27
+ - **`from_transport(..., rewrite_link=callback)`.** The callback is called with
28
+ a `Link` for each link and image the read meets outside code -- an image
29
+ before the link around it, with that link's `enclosing_href` -- and its
30
+ answer is written: `None` keeps the reader's output, a `str` is written as
31
+ literal text, a `Link` replaces the target (an empty `href` drops a link and
32
+ keeps its content, or writes an image as its text). The callback is held in
33
+ a context variable, so concurrent reads each use their own. What it raises
34
+ reaches the caller unchanged; a `ValueError` is not taken for markdownify's
35
+ and answered with the text fallback.
36
+ - **`Link` and `RewriteLink`**, exported from `easyvista_python_client.content`.
37
+ - **`EasyvistaContentConverter.document_id_of(src)`** returns the
38
+ `DOCUMENT_ID` of an embedded image's `src`
39
+ (`@@EMBEDDED_IMAGE_PATH@@<DOCUMENT_ID>`, how EasyVista's editor embeds a
40
+ pasted image), and **`document_image(document_id, alt=...)`** the Markdown
41
+ of one, spelled as the reader spells it. A `Link` for such an image carries
42
+ its `document_id`. Measured on one instance on 2026-10-07: an image written
43
+ that way through the API displays, for an attachment pasted in the editor
44
+ and for one uploaded with `add_document`.
45
+ - `EasyvistaContentConverter.EMBEDDED_IMAGE_PREFIX`.
46
+
47
+ ## [0.4.2] - 2026-10-07
48
+
49
+ A patch release of the content writer: a link written into a memo opens in a
50
+ new window. Reading is unchanged, and so is everything outside
51
+ `easyvista_python_client.content`.
52
+
53
+ ### Changed
54
+
55
+ - **Every link `to_transport` writes carries `target="_blank"
56
+ rel="noopener noreferrer"`**, as a link written in EasyVista's own editor
57
+ does. Without it, EasyVista's memo view opens the link in place, inside its
58
+ own window, so a link to another application seemed to do nothing (measured
59
+ on a live memo, 2026-10-07: links with and without the attributes, side by
60
+ side). Reading ignores both attributes, so `from_transport(to_transport(m))`
61
+ is unchanged for every link.
62
+
18
63
  ## [0.4.1] - 2026-10-02
19
64
 
20
65
  A patch release of the content reader. A `<u>`, `<mark>` or `<ins>` round a
@@ -1645,7 +1690,9 @@ Initial public release.
1645
1690
  status/error code, with non-retryable validation errors (HTTP 590, code 2013).
1646
1691
  - `py.typed` marker — the package ships inline type information.
1647
1692
 
1648
- [Unreleased]: https://github.com/baraline/easyvista_python_client/compare/0.4.1...HEAD
1693
+ [Unreleased]: https://github.com/baraline/easyvista_python_client/compare/0.4.3...HEAD
1694
+ [0.4.3]: https://github.com/baraline/easyvista_python_client/compare/0.4.2...0.4.3
1695
+ [0.4.2]: https://github.com/baraline/easyvista_python_client/compare/0.4.1...0.4.2
1649
1696
  [0.4.1]: https://github.com/baraline/easyvista_python_client/compare/0.4.0...0.4.1
1650
1697
  [0.4.0]: https://github.com/baraline/easyvista_python_client/compare/0.3.0...0.4.0
1651
1698
  [0.3.0]: https://github.com/baraline/easyvista_python_client/compare/0.2.0...0.3.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: easyvista-python-client
3
- Version: 0.4.1
3
+ Version: 0.4.3
4
4
  Summary: Typed Python client for the EasyVista Service Manager REST API
5
5
  Project-URL: Homepage, https://github.com/baraline/easyvista_python_client
6
6
  Project-URL: Documentation, https://easyvista-python-client.readthedocs.io/en/latest/
@@ -214,6 +214,11 @@ options, ``HARDBREAKS`` and ``UNSAFE``:
214
214
  * a fence becomes ``<pre><code>``, with the info string as a
215
215
  ``class="language-..."``;
216
216
  * a table becomes a ``<table>`` with a ``<thead>``;
217
+ * a link opens in a new window: each ``<a href>`` the renderer writes carries
218
+ ``target="_blank" rel="noopener noreferrer"``, as a link written in
219
+ EasyVista's editor does. Without it the memo view opens the link in place,
220
+ inside EasyVista's window (measured 2026-10-07). Reading ignores both
221
+ attributes, so a round trip is unchanged;
217
222
  * raw HTML, inline or as a block, is passed through as written;
218
223
  * ``""``, or only whitespace, stays ``""`` rather than becoming ``<p></p>``.
219
224
 
@@ -229,6 +234,60 @@ reported as
229
234
  :class:`~easyvista_python_client.exceptions.EasyvistaContentError`. A block
230
235
  quote nested 100,000 levels deep rendered without one (measured 2026-10-02).
231
236
 
237
+ Embedded images, and rewriting links as they are read
238
+ -----------------------------------------------------
239
+
240
+ EasyVista's editor keeps an image pasted into a memo as an attachment of the
241
+ request, named like ``image_emb_001.png``, and embeds it as
242
+ ``<img src="@@EMBEDDED_IMAGE_PATH@@<DOCUMENT_ID>" />``. Without a callback the
243
+ reader writes that as any other image, ``![](@@EMBEDDED_IMAGE_PATH@@...)``,
244
+ which displays nowhere but in EasyVista. Three helpers let a caller do better.
245
+
246
+ ``EasyvistaContentConverter.document_id_of(src)`` returns the ``DOCUMENT_ID``
247
+ an image's ``src`` embeds, or ``None``. ``document_image(document_id, alt=...)``
248
+ returns the Markdown of such an image, spelled exactly as the reader spells
249
+ it, so ``to_transport`` writes the editor's form and ``from_transport`` reads
250
+ it back unchanged. **Tier 4** -- measured on one instance on 2026-10-07: an
251
+ image written that way through the API displays, both for an attachment
252
+ pasted in the editor and for one uploaded with ``add_document``. The memo
253
+ displays it only while the attachment is on the same request.
254
+
255
+ ``from_transport(..., rewrite_link=callback)`` calls ``callback`` with a
256
+ :class:`~easyvista_python_client.content.Link` for each link and each image it
257
+ meets outside code, an image before the link around it. A ``Link`` carries the
258
+ ``href`` (an image's ``src``), the ``text`` (an image's ``alt``), the
259
+ ``title``, whether it is an ``image``, an embedded image's ``document_id``, and
260
+ for an image inside a link that link's ``enclosing_href``. What the callback
261
+ answers is written:
262
+
263
+ * ``None``: what the reader writes without a callback;
264
+ * a ``str``: that text, literally -- escaped as any text the memo displays, so
265
+ it cannot become markup;
266
+ * a ``Link``: a link's ``href`` and ``title`` around the link's content as
267
+ read, or an image's ``href`` as its ``src``, ``text`` as its ``alt`` and
268
+ ``title``;
269
+ * a ``Link`` with an empty ``href``: a link is dropped and its content kept, an
270
+ image is written as its ``text``.
271
+
272
+ .. code-block:: python
273
+
274
+ from easyvista_python_client.content import EasyvistaContentConverter, Link
275
+
276
+ def name_attachments(link: Link) -> Link | str | None:
277
+ if link.document_id is not None:
278
+ return f"[attachment {link.document_id}]"
279
+ return None
280
+
281
+ EasyvistaContentConverter.from_transport(memo, rewrite_link=name_attachments)
282
+
283
+ The callback is held for the duration of one read, per thread and per task,
284
+ so concurrent reads each use their own, and a read inside a callback has its
285
+ own too. What the callback raises reaches the caller unchanged -- a
286
+ ``ValueError`` included, which the reader would otherwise take for one of
287
+ markdownify's and answer by reading the body as its text. An answer of any
288
+ other type raises ``TypeError``. It is not called for a value passed through
289
+ as Markdown (``plain_text_is_markdown=True``), nor for a body read as its text.
290
+
232
291
  It is not a sanitiser
233
292
  ---------------------
234
293
 
@@ -5,4 +5,4 @@
5
5
  live only in ``__init__``.
6
6
  """
7
7
 
8
- __version__ = "0.4.1"
8
+ __version__ = "0.4.3"
@@ -13,6 +13,10 @@ stack left to report it otherwise (see ``docs/content.rst``).
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
- from easyvista_python_client.content.conversion import EasyvistaContentConverter
16
+ from easyvista_python_client.content.conversion import (
17
+ EasyvistaContentConverter,
18
+ Link,
19
+ RewriteLink,
20
+ )
17
21
 
18
- __all__ = ["EasyvistaContentConverter"]
22
+ __all__ = ["EasyvistaContentConverter", "Link", "RewriteLink"]
@@ -59,9 +59,11 @@ import re
59
59
  import string
60
60
  import unicodedata
61
61
  from collections.abc import Callable, Iterator, Mapping, MutableMapping, Sequence
62
+ from contextvars import ContextVar
63
+ from dataclasses import dataclass
62
64
  from functools import cached_property
63
65
  from html import escape, unescape
64
- from typing import Any, cast
66
+ from typing import Any, ClassVar, cast
65
67
 
66
68
  from easyvista_python_client.exceptions import EasyvistaContentError
67
69
 
@@ -396,6 +398,97 @@ def _title(title: str) -> str:
396
398
  return ' "' + re.sub(r'[\\"]', r"\\\g<0>", title) + '"' if title else ""
397
399
 
398
400
 
401
+ #: How a memo embeds an image: EasyVista's editor stores a pasted image as an
402
+ #: attachment of the request and writes ``<img src="@@EMBEDDED_IMAGE_PATH@@...">``
403
+ #: with that attachment's ``DOCUMENT_ID``. Measured 2026-10-07 on one instance
404
+ #: (tier 4): an image written that way through the API displays too, for an
405
+ #: attachment pasted in the editor and for one uploaded with ``add_document``.
406
+ EMBEDDED_IMAGE_PREFIX = "@@EMBEDDED_IMAGE_PATH@@"
407
+
408
+ #: A ``DOCUMENT_ID``, as an embedded image's ``src`` carries one. Measured: 40 hex
409
+ #: digits for an image pasted in the editor, an account number, ``_`` and 64 hex
410
+ #: digits for a file uploaded through the API. The class is wider than both.
411
+ _DOCUMENT_ID = re.compile(r"[A-Za-z0-9_.-]{1,128}")
412
+
413
+
414
+ @dataclass(frozen=True, slots=True)
415
+ class Link:
416
+ """A link or an image the reader meets, as a ``rewrite_link`` callback sees it.
417
+
418
+ Attributes
419
+ ----------
420
+ href : str
421
+ An ``<a>``'s ``href`` or an ``<img>``'s ``src``, character references
422
+ decoded.
423
+ text : str
424
+ An ``<a>``'s displayed text or an ``<img>``'s ``alt``, whitespace
425
+ collapsed.
426
+ title : str
427
+ The ``title``, or ``""``.
428
+ image : bool
429
+ ``True`` for an ``<img>``.
430
+ document_id : str or None
431
+ For an ``<img>`` embedding an attachment of the request
432
+ (:data:`EMBEDDED_IMAGE_PREFIX` and a ``DOCUMENT_ID``), that
433
+ ``DOCUMENT_ID``; ``None`` otherwise, and always for an ``<a>``.
434
+ enclosing_href : str or None
435
+ For an ``<img>`` inside a link, that link's ``href``; ``None``
436
+ otherwise. The image is read before the link around it.
437
+ """
438
+
439
+ href: str
440
+ text: str = ""
441
+ title: str = ""
442
+ image: bool = False
443
+ document_id: str | None = None
444
+ enclosing_href: str | None = None
445
+
446
+
447
+ #: A ``rewrite_link`` callback: called with each link and image a read meets,
448
+ #: outside code. ``None`` keeps what the reader writes without one. A ``str`` is
449
+ #: written instead, as literal text. A :class:`Link` is written in the reader's
450
+ #: own spelling: a link's ``href`` and ``title``, around the link's content as
451
+ #: read; an image's ``href`` as its ``src``, ``text`` as its ``alt`` and
452
+ #: ``title``. An empty ``href`` drops a link and keeps its content, and writes an
453
+ #: image as its ``text``.
454
+ RewriteLink = Callable[[Link], "Link | str | None"]
455
+
456
+ _REWRITE: ContextVar[RewriteLink | None] = ContextVar("rewrite_link", default=None)
457
+ """The callback of the read in progress. A context variable rather than state
458
+ on the shared converter: each thread and each task reads with its own."""
459
+
460
+
461
+ class _CallbackRaised(Exception):
462
+ """What a ``rewrite_link`` callback raised, carried past the reader's fallbacks.
463
+
464
+ The reader answers a ``ValueError`` by reading the body as its text; a
465
+ callback's own ``ValueError`` must not pass for markdownify's and quietly
466
+ cost the body its formatting.
467
+ """
468
+
469
+ def __init__(self, error: Exception) -> None:
470
+ super().__init__(error)
471
+ self.error = error
472
+
473
+
474
+ def _rewritten(link: Link) -> Link | str | None:
475
+ """Ask the read's callback about ``link``; ``None`` when there is none."""
476
+
477
+ rewrite = _REWRITE.get()
478
+ if rewrite is None:
479
+ return None
480
+ try:
481
+ answer: object = rewrite(link) # typed as what it is, not what it should be
482
+ except Exception as exc:
483
+ raise _CallbackRaised(exc) from exc
484
+ if answer is None or isinstance(answer, (Link, str)):
485
+ return answer
486
+ error = TypeError(
487
+ f"rewrite_link returned {type(answer).__name__}; expected Link, str or None"
488
+ )
489
+ raise _CallbackRaised(error)
490
+
491
+
399
492
  class _Converter(MarkdownConverter):
400
493
  """``markdownify``, with what CommonMark and the reader's glue need on top.
401
494
 
@@ -553,15 +646,27 @@ class _Converter(MarkdownConverter):
553
646
  body, _, tail = rest.rpartition("```")
554
647
  return f"{head}{fence}{body}{fence}{tail}"
555
648
 
649
+ def _literal(self, text: str, parent_tags: set[str]) -> str:
650
+ """``text`` escaped as the reader escapes a text node: displayed as it is."""
651
+
652
+ return self.escape(" ".join(text.split()), parent_tags)
653
+
556
654
  def convert_a(self, el: Tag, text: str, parent_tags: set[str]) -> str:
557
655
  """A link; ``<url>`` when its text is its URL and reads back unchanged."""
558
656
 
657
+ if "_noformat" in parent_tags:
658
+ return text
559
659
  href = str(el.get("href") or "")
660
+ title = str(el.get("title") or "")
661
+ answer = _rewritten(Link(href, " ".join(el.get_text().split()), title))
662
+ if isinstance(answer, str):
663
+ return self._literal(answer, parent_tags)
664
+ if answer is not None:
665
+ href, title = answer.href, answer.title
560
666
  edges = _EDGES.fullmatch(text)
561
- if "_noformat" in parent_tags or not href or edges is None or not edges[2]:
667
+ if not href or edges is None or not edges[2]:
562
668
  return text
563
669
  before, inner, after = edges.groups()
564
- title = str(el.get("title") or "")
565
670
  if not title and el.get_text() == href and _AUTOLINK.fullmatch(href):
566
671
  return f"{before}<{href}>{after}"
567
672
  return f"{before}[{inner}]({_destination(href)}{_title(title)}){after}"
@@ -569,12 +674,38 @@ class _Converter(MarkdownConverter):
569
674
  def convert_img(self, el: Tag, text: str, parent_tags: set[str]) -> str:
570
675
  """An image, wherever it is: a cell or a heading holds one too."""
571
676
 
677
+ src = str(el.get("src") or "")
572
678
  alt = " ".join(str(el.get("alt") or "").split())
679
+ title = str(el.get("title") or "")
680
+ if "_noformat" not in parent_tags:
681
+ embedded = EasyvistaContentConverter.document_id_of(src)
682
+ around = el.find_parent("a")
683
+ answer = _rewritten(
684
+ Link(
685
+ src,
686
+ alt,
687
+ title,
688
+ image=True,
689
+ document_id=embedded,
690
+ enclosing_href=(
691
+ None if around is None else str(around.get("href") or "")
692
+ ),
693
+ )
694
+ )
695
+ if isinstance(answer, str):
696
+ return self._literal(answer, parent_tags)
697
+ if answer is not None and not answer.href:
698
+ return self._literal(answer.text, parent_tags)
699
+ if answer is not None:
700
+ src, alt, title = (
701
+ answer.href,
702
+ " ".join(answer.text.split()),
703
+ answer.title,
704
+ )
573
705
  alt = str(self._inherited("escape")(alt, parent_tags))
574
706
  if alt.startswith("^") and "_noformat" not in parent_tags:
575
707
  alt = "\\" + alt # cmark-gfm reads "![^" as "!" and a link
576
- src = _destination(str(el.get("src") or ""))
577
- return f"![{alt}]({src}{_title(str(el.get('title') or ''))})"
708
+ return f"![{alt}]({_destination(src)}{_title(title)})"
578
709
 
579
710
 
580
711
  class _Node(RenderTreeNode):
@@ -748,12 +879,26 @@ def html_to_markdown(html: str) -> str:
748
879
  return str(_FORMATTER.render(_CONVERTER.convert_soup(soup))).strip()
749
880
 
750
881
 
882
+ #: A link as cmark-gfm writes one: an ``href``, an optional ``title``, nothing else.
883
+ _RENDERED_LINK = re.compile(r'<a href="[^"]*"(?: title="[^"]*")?>')
884
+
885
+ #: What EasyVista's own editor writes on a link: without it the memo view opens the
886
+ #: link in place, inside EasyVista's window, rather than in a new one.
887
+ _NEW_WINDOW = ' target="_blank" rel="noopener noreferrer"'
888
+
889
+
751
890
  def markdown_to_html(markdown: str) -> str:
752
- """Render Markdown as HTML: CommonMark with GFM tables, through cmark-gfm."""
891
+ """Render Markdown as HTML: CommonMark with GFM tables, through cmark-gfm.
892
+
893
+ Every link opens in a new window, the way a link written in EasyVista's
894
+ editor does: ``target="_blank" rel="noopener noreferrer"`` is added to each
895
+ link cmark-gfm writes. Reading ignores both attributes.
896
+ """
753
897
 
754
898
  html: str = cmarkgfm.markdown_to_html_with_extensions(
755
899
  markdown, options=_RENDER_OPTIONS, extensions=["table"]
756
900
  )
901
+ html = _RENDERED_LINK.sub(lambda link: link.group(0)[:-1] + _NEW_WINDOW + ">", html)
757
902
  return html.strip()
758
903
 
759
904
 
@@ -770,11 +915,41 @@ def _text_of(html: str) -> str:
770
915
  return "\n".join(line for line in lines if line)
771
916
 
772
917
 
918
+ def _converted(content: str) -> str:
919
+ """Read memo HTML, falling back to its text where the conversion cannot."""
920
+
921
+ try:
922
+ try:
923
+ return html_to_markdown(content)
924
+ except (RecursionError, ValueError): # ValueError: markdownify's
925
+ # int() of a colspan or start such as "²" or 5,000 digits
926
+ return html_to_markdown(_plain_text_html(_text_of(content)))
927
+ except ParserRejectedMarkup:
928
+ # html.parser gives up on a few malformed declarations; the
929
+ # body's words are still worth more than an exception.
930
+ text = unescape(_ANY_TAG.sub(" ", content))
931
+ return html_to_markdown(_plain_text_html(" ".join(text.split())))
932
+ except _CallbackRaised:
933
+ raise
934
+ except Exception as exc:
935
+ raise EasyvistaContentError(
936
+ "Could not convert EasyVista memo HTML to Markdown "
937
+ f"({type(exc).__name__}: {exc})."
938
+ ) from exc
939
+
940
+
773
941
  class EasyvistaContentConverter:
774
942
  """Convert content between EasyVista memo HTML and canonical Markdown."""
775
943
 
944
+ EMBEDDED_IMAGE_PREFIX: ClassVar[str] = EMBEDDED_IMAGE_PREFIX
945
+
776
946
  @staticmethod
777
- def from_transport(value: object, *, plain_text_is_markdown: bool = False) -> str:
947
+ def from_transport(
948
+ value: object,
949
+ *,
950
+ plain_text_is_markdown: bool = False,
951
+ rewrite_link: RewriteLink | None = None,
952
+ ) -> str:
778
953
  """Convert one EasyVista memo value into Markdown.
779
954
 
780
955
  Parameters
@@ -792,6 +967,12 @@ class EasyvistaContentConverter:
792
967
  ``<kbd>`` is still Markdown, but Markdown that opens with an
793
968
  autolink or other angle-bracketed text and carries inline HTML
794
969
  further on is read as HTML, and loses that autolink.
970
+ rewrite_link : callable, optional
971
+ Called with a :class:`Link` for each link and image the read
972
+ meets outside code, an image before the link around it; its
973
+ answer decides what is written (:data:`RewriteLink`). Not called
974
+ for a value passed through as Markdown, nor for a body read as
975
+ its text. Without one, the read is what it always was.
795
976
 
796
977
  Returns
797
978
  -------
@@ -811,6 +992,10 @@ class EasyvistaContentConverter:
811
992
  limit, where no stack is left even to report the failure as
812
993
  ``EasyvistaContentError`` (``docs/content.rst`` gives the
813
994
  measured depths).
995
+ Exception
996
+ Whatever ``rewrite_link`` raised, unchanged; ``TypeError`` when it
997
+ answered something other than a :class:`Link`, a ``str`` or
998
+ ``None``.
814
999
  """
815
1000
 
816
1001
  content = str(value or "").strip()
@@ -826,22 +1011,65 @@ class EasyvistaContentConverter:
826
1011
  # 3.11.14/3.12.12/3.13.6 rescans to the end for each (quadratic).
827
1012
  head, end, tail = content.rpartition(">")
828
1013
  content = head + end + tail.replace("<", "&lt;")
1014
+ token = _REWRITE.set(rewrite_link)
829
1015
  try:
830
- try:
831
- return html_to_markdown(content)
832
- except (RecursionError, ValueError): # ValueError: markdownify's
833
- # int() of a colspan or start such as "²" or 5,000 digits
834
- return html_to_markdown(_plain_text_html(_text_of(content)))
835
- except ParserRejectedMarkup:
836
- # html.parser gives up on a few malformed declarations; the
837
- # body's words are still worth more than an exception.
838
- text = unescape(_ANY_TAG.sub(" ", content))
839
- return html_to_markdown(_plain_text_html(" ".join(text.split())))
840
- except Exception as exc:
841
- raise EasyvistaContentError(
842
- "Could not convert EasyVista memo HTML to Markdown "
843
- f"({type(exc).__name__}: {exc})."
844
- ) from exc
1016
+ return _converted(content)
1017
+ except _CallbackRaised as raised:
1018
+ callback_error = raised.error
1019
+ finally:
1020
+ _REWRITE.reset(token)
1021
+ raise callback_error # outside the handler: the callback's, as it raised it
1022
+
1023
+ @staticmethod
1024
+ def document_id_of(src: str) -> str | None:
1025
+ """Return the ``DOCUMENT_ID`` an embedded image's ``src`` names.
1026
+
1027
+ ``None`` unless ``src`` is exactly :data:`EMBEDDED_IMAGE_PREFIX`
1028
+ followed by a ``DOCUMENT_ID`` -- how EasyVista's editor refers to an
1029
+ image pasted into a memo, which it keeps as an attachment of the
1030
+ request.
1031
+ """
1032
+
1033
+ if not src.startswith(EMBEDDED_IMAGE_PREFIX):
1034
+ return None
1035
+ document_id = src[len(EMBEDDED_IMAGE_PREFIX) :]
1036
+ return document_id if _DOCUMENT_ID.fullmatch(document_id) else None
1037
+
1038
+ @staticmethod
1039
+ def document_image(document_id: str, *, alt: str = "") -> str:
1040
+ """Return the Markdown of an image embedding one of the request's attachments.
1041
+
1042
+ :meth:`to_transport` renders it as EasyVista's editor writes a pasted
1043
+ image, ``<img src="@@EMBEDDED_IMAGE_PATH@@<document_id>" alt="..." />``,
1044
+ and :meth:`from_transport` reads that back as this same Markdown: it
1045
+ is spelled by the reader itself. The memo displays the image once the
1046
+ attachment is on the same request.
1047
+
1048
+ Parameters
1049
+ ----------
1050
+ document_id : str
1051
+ The attachment's ``DOCUMENT_ID``, as ``list_documents`` gives it.
1052
+ alt : str, optional
1053
+ The image's alternative text.
1054
+
1055
+ Raises
1056
+ ------
1057
+ ValueError
1058
+ ``document_id`` is not one: empty, or holding a character no
1059
+ ``DOCUMENT_ID`` has.
1060
+ """
1061
+
1062
+ if not _DOCUMENT_ID.fullmatch(document_id):
1063
+ raise ValueError(f"not an EasyVista DOCUMENT_ID: {document_id!r}")
1064
+ native = (
1065
+ f'<p><img src="{EMBEDDED_IMAGE_PREFIX}{document_id}" '
1066
+ f'alt="{escape(" ".join(alt.split()))}" /></p>'
1067
+ )
1068
+ token = _REWRITE.set(None) # a callback in force for an enclosing read
1069
+ try:
1070
+ return html_to_markdown(native)
1071
+ finally:
1072
+ _REWRITE.reset(token)
845
1073
 
846
1074
  @staticmethod
847
1075
  def to_transport(value: object) -> str:
@@ -44,7 +44,7 @@ exclude = [
44
44
 
45
45
  [project]
46
46
  name = "easyvista-python-client"
47
- version = "0.4.1"
47
+ version = "0.4.3"
48
48
  description = "Typed Python client for the EasyVista Service Manager REST API"
49
49
  readme = "README.md"
50
50
  requires-python = ">=3.11"
@@ -5,7 +5,7 @@ license: MIT
5
5
  compatibility: "Requires Python 3.11+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and a profile authorized for the assets resource."
6
6
  metadata:
7
7
  package: easyvista-python-client
8
- version: "0.4.1"
8
+ version: "0.4.3"
9
9
  ---
10
10
 
11
11
  > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`,
@@ -5,7 +5,7 @@ license: MIT
5
5
  compatibility: "Requires Python 3.11+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and valid EasyVista credentials."
6
6
  metadata:
7
7
  package: easyvista-python-client
8
- version: "0.4.1"
8
+ version: "0.4.3"
9
9
  ---
10
10
 
11
11
  `easyvista_python_client` ships both clients over one surface:
@@ -5,7 +5,7 @@ license: MIT
5
5
  compatibility: "Requires Python 3.11+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and a profile authorized for the departments and employees resources (writes are additionally profile-gated)."
6
6
  metadata:
7
7
  package: easyvista-python-client
8
- version: "0.4.1"
8
+ version: "0.4.3"
9
9
  ---
10
10
 
11
11
  > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`,
@@ -5,7 +5,7 @@ license: MIT
5
5
  compatibility: "Requires Python 3.11+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and a profile authorized for the documents sub-resource."
6
6
  metadata:
7
7
  package: easyvista-python-client
8
- version: "0.4.1"
8
+ version: "0.4.3"
9
9
  ---
10
10
 
11
11
  > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`,
@@ -5,7 +5,7 @@ license: MIT
5
5
  compatibility: "Requires Python 3.11+, easyvista-python-client, and network access to an EasyVista Service Manager REST API. Every call here is a GET; nothing is created, updated or deleted."
6
6
  metadata:
7
7
  package: easyvista-python-client
8
- version: "0.4.1"
8
+ version: "0.4.3"
9
9
  ---
10
10
 
11
11
  > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`,
@@ -5,7 +5,7 @@ license: MIT
5
5
  compatibility: "Requires Python 3.11+, easyvista-python-client, and network access to an EasyVista Service Manager REST API."
6
6
  metadata:
7
7
  package: easyvista-python-client
8
- version: "0.4.1"
8
+ version: "0.4.3"
9
9
  ---
10
10
 
11
11
  > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`,
@@ -5,7 +5,7 @@ license: MIT
5
5
  compatibility: "Requires Python 3.11+, easyvista-python-client, and network access to an EasyVista Service Manager REST API."
6
6
  metadata:
7
7
  package: easyvista-python-client
8
- version: "0.4.1"
8
+ version: "0.4.3"
9
9
  ---
10
10
 
11
11
  > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`,
@@ -5,7 +5,7 @@ license: MIT
5
5
  compatibility: "Requires Python 3.11+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and a profile authorized for the actions sub-resource."
6
6
  metadata:
7
7
  package: easyvista-python-client
8
- version: "0.4.1"
8
+ version: "0.4.3"
9
9
  ---
10
10
 
11
11
  > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`,
@@ -5,7 +5,7 @@ license: MIT
5
5
  compatibility: "Requires Python 3.11+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and a profile authorized for the requests resource."
6
6
  metadata:
7
7
  package: easyvista-python-client
8
- version: "0.4.1"
8
+ version: "0.4.3"
9
9
  ---
10
10
 
11
11
  > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`,