euporie 2.3.2__py3-none-any.whl → 2.4.1__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.
Files changed (92) hide show
  1. euporie/console/__main__.py +3 -1
  2. euporie/console/app.py +6 -4
  3. euporie/console/tabs/console.py +34 -9
  4. euporie/core/__init__.py +6 -1
  5. euporie/core/__main__.py +1 -1
  6. euporie/core/app.py +79 -109
  7. euporie/core/border.py +44 -14
  8. euporie/core/comm/base.py +5 -4
  9. euporie/core/comm/ipywidgets.py +11 -11
  10. euporie/core/comm/registry.py +12 -6
  11. euporie/core/commands.py +30 -23
  12. euporie/core/completion.py +1 -4
  13. euporie/core/config.py +15 -5
  14. euporie/core/convert/{base.py → core.py} +117 -53
  15. euporie/core/convert/formats/ansi.py +46 -25
  16. euporie/core/convert/formats/base64.py +3 -3
  17. euporie/core/convert/formats/common.py +38 -13
  18. euporie/core/convert/formats/formatted_text.py +54 -12
  19. euporie/core/convert/formats/html.py +5 -5
  20. euporie/core/convert/formats/jpeg.py +1 -1
  21. euporie/core/convert/formats/markdown.py +4 -4
  22. euporie/core/convert/formats/pdf.py +1 -1
  23. euporie/core/convert/formats/pil.py +5 -3
  24. euporie/core/convert/formats/png.py +7 -6
  25. euporie/core/convert/formats/rich.py +4 -3
  26. euporie/core/convert/formats/sixel.py +5 -5
  27. euporie/core/convert/utils.py +1 -1
  28. euporie/core/current.py +11 -5
  29. euporie/core/formatted_text/ansi.py +4 -8
  30. euporie/core/formatted_text/html.py +1630 -856
  31. euporie/core/formatted_text/markdown.py +177 -166
  32. euporie/core/formatted_text/table.py +20 -14
  33. euporie/core/formatted_text/utils.py +21 -10
  34. euporie/core/io.py +14 -14
  35. euporie/core/kernel.py +48 -37
  36. euporie/core/key_binding/bindings/micro.py +5 -1
  37. euporie/core/key_binding/bindings/mouse.py +2 -2
  38. euporie/core/keys.py +3 -0
  39. euporie/core/launch.py +5 -2
  40. euporie/core/lexers.py +13 -2
  41. euporie/core/log.py +135 -139
  42. euporie/core/margins.py +32 -14
  43. euporie/core/path.py +273 -0
  44. euporie/core/processors.py +35 -0
  45. euporie/core/renderer.py +21 -5
  46. euporie/core/style.py +34 -19
  47. euporie/core/tabs/base.py +101 -17
  48. euporie/core/tabs/notebook.py +72 -30
  49. euporie/core/terminal.py +56 -48
  50. euporie/core/utils.py +12 -16
  51. euporie/core/widgets/cell.py +6 -5
  52. euporie/core/widgets/cell_outputs.py +2 -2
  53. euporie/core/widgets/decor.py +74 -82
  54. euporie/core/widgets/dialog.py +132 -28
  55. euporie/core/widgets/display.py +76 -24
  56. euporie/core/widgets/file_browser.py +87 -31
  57. euporie/core/widgets/formatted_text_area.py +1 -3
  58. euporie/core/widgets/forms.py +79 -40
  59. euporie/core/widgets/inputs.py +23 -13
  60. euporie/core/widgets/layout.py +4 -3
  61. euporie/core/widgets/menu.py +368 -216
  62. euporie/core/widgets/page.py +99 -58
  63. euporie/core/widgets/pager.py +1 -1
  64. euporie/core/widgets/palette.py +30 -27
  65. euporie/core/widgets/search_bar.py +38 -25
  66. euporie/core/widgets/status_bar.py +103 -5
  67. euporie/data/desktop/euporie-console.desktop +7 -0
  68. euporie/data/desktop/euporie-notebook.desktop +7 -0
  69. euporie/hub/__main__.py +3 -1
  70. euporie/hub/app.py +9 -7
  71. euporie/notebook/__main__.py +3 -1
  72. euporie/notebook/app.py +7 -30
  73. euporie/notebook/tabs/__init__.py +7 -3
  74. euporie/notebook/tabs/display.py +18 -9
  75. euporie/notebook/tabs/edit.py +106 -23
  76. euporie/notebook/tabs/json.py +73 -0
  77. euporie/notebook/tabs/log.py +18 -8
  78. euporie/notebook/tabs/notebook.py +60 -41
  79. euporie/preview/__main__.py +3 -1
  80. euporie/preview/app.py +2 -1
  81. euporie/preview/tabs/notebook.py +23 -10
  82. euporie/web/tabs/web.py +149 -0
  83. euporie/web/widgets/webview.py +563 -0
  84. euporie-2.4.1.data/data/share/applications/euporie-console.desktop +7 -0
  85. euporie-2.4.1.data/data/share/applications/euporie-notebook.desktop +7 -0
  86. {euporie-2.3.2.dist-info → euporie-2.4.1.dist-info}/METADATA +6 -5
  87. euporie-2.4.1.dist-info/RECORD +129 -0
  88. {euporie-2.3.2.dist-info → euporie-2.4.1.dist-info}/WHEEL +1 -1
  89. euporie/core/url.py +0 -64
  90. euporie-2.3.2.dist-info/RECORD +0 -122
  91. {euporie-2.3.2.dist-info → euporie-2.4.1.dist-info}/entry_points.txt +0 -0
  92. {euporie-2.3.2.dist-info → euporie-2.4.1.dist-info}/licenses/LICENSE +0 -0
@@ -41,7 +41,7 @@ from euporie.core.widgets.forms import (
41
41
  from euporie.core.widgets.layout import AccordionSplit, ReferencedSplit, TabbedSplit
42
42
 
43
43
  if TYPE_CHECKING:
44
- from typing import Any, Iterable, Sequence
44
+ from typing import Any, Iterable, MutableSequence, Sequence
45
45
 
46
46
  from prompt_toolkit.buffer import Buffer
47
47
  from prompt_toolkit.formatted_text.base import AnyFormattedText
@@ -66,7 +66,7 @@ def _separate_buffers(
66
66
  substate: dict | list | tuple,
67
67
  path: list[str | int],
68
68
  buffer_paths: list[list[str | int]],
69
- buffers: list[memoryview | bytearray | bytes],
69
+ buffers: MutableSequence[memoryview | bytearray | bytes],
70
70
  ) -> dict | list | tuple:
71
71
  """Remove binary types from dicts and lists, but keep track of their paths.
72
72
 
@@ -118,7 +118,7 @@ def _separate_buffers(
118
118
  cloned_substrate[k] = vnew
119
119
  else:
120
120
  raise ValueError("expected state to be a list or dict, not %r" % substate)
121
- return cloned_substrate or substate
121
+ return cloned_substrate if cloned_substrate is not None else substate
122
122
 
123
123
 
124
124
  class IpyWidgetComm(Comm, metaclass=ABCMeta):
@@ -190,7 +190,7 @@ class IpyWidgetComm(Comm, metaclass=ABCMeta):
190
190
  "model_module": state.get("_model_module"),
191
191
  "model_module_version": state.get("_model_module_version"),
192
192
  "state": {
193
- **{key: value for key, value in state.items()},
193
+ **dict(state.items()),
194
194
  "buffers": [
195
195
  {
196
196
  "encoding": "base64",
@@ -292,7 +292,7 @@ class OutputModel(IpyWidgetComm):
292
292
 
293
293
 
294
294
  class LayoutIpyWidgetComm(IpyWidgetComm, metaclass=ABCMeta):
295
- """Bae class for layout widgets with children."""
295
+ """Base class for layout widgets with children."""
296
296
 
297
297
  def render_children(
298
298
  self, models: list[str], parent: OutputParent
@@ -644,7 +644,7 @@ class FloatLogOptionsMixin(FloatOptionsMixin):
644
644
 
645
645
 
646
646
  class SliderIpyWidgetComm(IpyWidgetComm, metaclass=ABCMeta):
647
- """Bae class for slider ipywidgets."""
647
+ """Base class for slider ipywidgets."""
648
648
 
649
649
  def normalize(self, x: Any) -> Any:
650
650
  """Convert the internal widget's value to one compatible with the ipywidget."""
@@ -722,7 +722,7 @@ class SliderIpyWidgetComm(IpyWidgetComm, metaclass=ABCMeta):
722
722
 
723
723
 
724
724
  class RangeSliderIpyWidgetComm(SliderIpyWidgetComm):
725
- """Bae class for range slider ipywidgets."""
725
+ """Base class for range slider ipywidgets."""
726
726
 
727
727
  @property
728
728
  def indices(self) -> list[int]:
@@ -773,7 +773,7 @@ class FloatRangeSliderModel(FloatOptionsMixin, RangeSliderIpyWidgetComm):
773
773
 
774
774
 
775
775
  class NumberTextBoxIpyWidgetComm(TextBoxIpyWidgetComm, metaclass=ABCMeta):
776
- """Bae class for text-box ipywidgets with numerical values."""
776
+ """Base class for text-box ipywidgets with numerical values."""
777
777
 
778
778
  def create_view(self, parent: OutputParent) -> CommView:
779
779
  """Create a new view of the numerical text-box ipywidget."""
@@ -899,7 +899,7 @@ class FloatProgressModel(FloatOptionsMixin, ProgressIpyWidgetComm):
899
899
 
900
900
 
901
901
  class ToggleableIpyWidgetComm(IpyWidgetComm, metaclass=ABCMeta):
902
- """Bae class for toggleable ipywidgets."""
902
+ """Base class for toggleable ipywidgets."""
903
903
 
904
904
  def normalize(self, x: Any) -> float | None:
905
905
  """Cat the container's selected value to a bool if possible."""
@@ -994,7 +994,7 @@ class ValidModel(ToggleableIpyWidgetComm):
994
994
 
995
995
 
996
996
  class SelectableIpyWidgetComm(IpyWidgetComm, metaclass=ABCMeta):
997
- """Bae class for selectable ipywidgets."""
997
+ """Base class for selectable ipywidgets."""
998
998
 
999
999
  def update_index(self, container: SelectableWidget) -> None:
1000
1000
  """Send a ``comm_message`` updating the selected index when it changes."""
@@ -1098,7 +1098,7 @@ class SelectMultipleModel(IpyWidgetComm):
1098
1098
  options=self.data["state"].get("_options_labels", []),
1099
1099
  indices=self.data["state"]["index"],
1100
1100
  on_change=self.update_index,
1101
- style="class:select,face",
1101
+ style="class:select",
1102
1102
  multiple=True,
1103
1103
  rows=self.data["state"]["rows"],
1104
1104
  disabled=Condition(lambda: self.data["state"].get("disabled", False)),
@@ -8,11 +8,13 @@ from euporie.core.comm.base import UnimplementedComm
8
8
  from euporie.core.comm.ipywidgets import open_comm_ipywidgets
9
9
 
10
10
  if TYPE_CHECKING:
11
- from typing import Any, Sequence
11
+ from typing import Any, Callable, Sequence
12
12
 
13
13
  from euporie.core.comm.base import Comm, KernelTab
14
14
 
15
- TARGET_CLASSES = {"jupyter.widget": open_comm_ipywidgets}
15
+ TARGET_CLASSES: dict[str, Callable[[KernelTab, str, dict, Sequence[bytes]], Comm]] = {
16
+ "jupyter.widget": open_comm_ipywidgets
17
+ }
16
18
 
17
19
 
18
20
  def open_comm(
@@ -36,8 +38,12 @@ def open_comm(
36
38
  """
37
39
  target_name = content.get("target_name", "")
38
40
  return TARGET_CLASSES.get(target_name, UnimplementedComm)(
39
- comm_container=comm_container,
40
- comm_id=str(content.get("comm_id")),
41
- data=content.get("data", {}),
42
- buffers=buffers,
41
+ # comm_container=
42
+ comm_container,
43
+ # comm_id=
44
+ str(content.get("comm_id")),
45
+ # data=
46
+ content.get("data", {}),
47
+ # buffers=
48
+ buffers,
43
49
  )
euporie/core/commands.py CHANGED
@@ -16,18 +16,26 @@ from euporie.core.key_binding.utils import parse_keys
16
16
  from euporie.core.keys import Keys
17
17
 
18
18
  if TYPE_CHECKING:
19
- from typing import Any, Callable, Coroutine, Sequence
19
+ from typing import Any, Callable, Coroutine
20
20
 
21
21
  from prompt_toolkit.filters import Filter, FilterOrBool
22
22
  from prompt_toolkit.key_binding.key_bindings import (
23
23
  KeyBindingsBase,
24
24
  KeyHandlerCallable,
25
+ NotImplementedOrNone,
25
26
  )
26
27
 
27
28
  from euporie.core.widgets.menu import MenuItem
28
29
 
29
30
  AnyKey = tuple[Keys | str, ...] | Keys | str
30
31
  AnyKeys = list[AnyKey] | AnyKey
32
+ CommandHandlerNoArgs = Callable[
33
+ ..., Coroutine[Any, Any, None] | NotImplementedOrNone
34
+ ]
35
+ CommandHandlerArgs = Callable[
36
+ [KeyPressEvent], Coroutine[Any, Any, None] | NotImplementedOrNone
37
+ ]
38
+ CommandHandler = CommandHandlerNoArgs | CommandHandlerArgs
31
39
 
32
40
  log = logging.getLogger(__name__)
33
41
 
@@ -37,7 +45,7 @@ class Command:
37
45
 
38
46
  def __init__(
39
47
  self,
40
- handler: Callable[..., Coroutine[Any, Any, None] | None],
48
+ handler: CommandHandler,
41
49
  *,
42
50
  filter: FilterOrBool = True,
43
51
  hidden: FilterOrBool = False,
@@ -98,9 +106,6 @@ class Command:
98
106
 
99
107
  self.keys: list[tuple[str | Keys, ...]] = []
100
108
 
101
- self.selected_item = 0
102
- self.children: Sequence[MenuItem] = []
103
-
104
109
  def run(self) -> None:
105
110
  """Run the command's handler."""
106
111
  if self.filter():
@@ -117,28 +122,28 @@ class Command:
117
122
  @property
118
123
  def key_handler(self) -> KeyHandlerCallable:
119
124
  """Return a key handler for the command."""
120
- sig = signature(self.handler)
125
+ handler = self.handler
126
+ sig = signature(handler)
121
127
 
122
128
  if sig.parameters:
123
129
  # The handler already accepts a `KeyPressEvent` argument
124
- return cast("KeyHandlerCallable", self.handler)
130
+ return cast("KeyHandlerCallable", handler)
131
+
132
+ if isawaitable(handler):
125
133
 
126
- def _key_handler(event: KeyPressEvent) -> None:
127
- result = self.handler()
128
- # If the handler is a coroutine, create an asyncio task.
129
- if isawaitable(result):
130
- awaitable = result
134
+ async def _key_handler_async(event: KeyPressEvent) -> NotImplementedOrNone:
135
+ result = cast("CommandHandlerNoArgs", handler)()
136
+ assert isawaitable(result)
137
+ return await result
131
138
 
132
- async def bg_task() -> None:
133
- result = await awaitable
134
- if result != NotImplemented:
135
- event.app.invalidate()
139
+ return _key_handler_async
140
+
141
+ else:
136
142
 
137
- event.app.create_background_task(bg_task())
138
- elif result != NotImplemented:
139
- event.app.invalidate()
143
+ def _key_handler(event: KeyPressEvent) -> NotImplementedOrNone:
144
+ return cast("CommandHandlerNoArgs", handler)()
140
145
 
141
- return _key_handler
146
+ return _key_handler
142
147
 
143
148
  def bind(self, key_bindings: KeyBindingsBase, keys: AnyKeys) -> None:
144
149
  """Add the current commands to a set of key bindings.
@@ -173,16 +178,18 @@ class Command:
173
178
  @property
174
179
  def menu_handler(self) -> Callable[[], None]:
175
180
  """Return a menu handler for the command."""
176
- if isawaitable(self.handler):
181
+ handler = self.handler
182
+ if isawaitable(handler):
177
183
 
178
184
  def _menu_handler() -> None:
179
- task = self.handler()
185
+ task = cast("CommandHandlerNoArgs", handler)()
186
+ task = cast("Coroutine[Any, Any, None]", task)
180
187
  if task is not None:
181
188
  get_app().create_background_task(task)
182
189
 
183
190
  return _menu_handler
184
191
  else:
185
- return cast("Callable[[], None]", self.handler)
192
+ return cast("Callable[[], None]", handler)
186
193
 
187
194
  @property
188
195
  def menu(self) -> MenuItem:
@@ -41,8 +41,5 @@ class KernelCompleter(Completer):
41
41
  cursor_pos=document.cursor_position,
42
42
  ):
43
43
  if completion_type := kwargs.get("display_meta"):
44
- kwargs["style"] = f"class:completion-menu.completion.{completion_type}"
45
- kwargs[
46
- "selected_style"
47
- ] = f"class:completion-menu.completion.current.{completion_type}"
44
+ kwargs["style"] = f"class:completion-{completion_type}"
48
45
  yield Completion(**kwargs)
euporie/core/config.py CHANGED
@@ -6,6 +6,7 @@ import argparse
6
6
  import json
7
7
  import logging
8
8
  import os
9
+ import sys
9
10
  from ast import literal_eval
10
11
  from collections import ChainMap
11
12
  from functools import partial
@@ -13,7 +14,7 @@ from pathlib import Path
13
14
  from typing import TYPE_CHECKING, Protocol, TextIO, cast
14
15
 
15
16
  import fastjsonschema
16
- from appdirs import user_config_dir
17
+ from platformdirs import user_config_dir
17
18
  from prompt_toolkit.filters.base import Condition
18
19
  from prompt_toolkit.filters.utils import to_filter
19
20
  from prompt_toolkit.utils import Event
@@ -21,7 +22,6 @@ from upath import UPath
21
22
 
22
23
  from euporie.core import __app_name__, __copyright__, __version__
23
24
  from euporie.core.commands import add_cmd, get_cmd
24
- from euporie.core.log import setup_logs
25
25
 
26
26
  if TYPE_CHECKING:
27
27
  from typing import IO, Any, Callable, Optional, Sequence
@@ -339,6 +339,8 @@ class Config:
339
339
 
340
340
  def load(self, cls: type[ConfigurableApp]) -> None:
341
341
  """Load the command line, environment, and user configuration."""
342
+ from euporie.core.log import setup_logs
343
+
342
344
  self.app_cls = cls
343
345
  self.app_name = cls.name
344
346
  log.debug("Loading config for %s", self.app_name)
@@ -416,7 +418,11 @@ class Config:
416
418
  def load_args(self) -> dict[str, Any]:
417
419
  """Attempt to load configuration settings from commandline flags."""
418
420
  result = {}
419
- namespace, _ = self.load_parser().parse_known_intermixed_args()
421
+ # Parse known arguments
422
+ namespace, remainder = self.load_parser().parse_known_intermixed_args()
423
+ # Update argv to leave the remaining arguments for subsequent apps
424
+ sys.argv[1:] = remainder
425
+ # Validate arguments
420
426
  for name, value in vars(namespace).items():
421
427
  if value is not None:
422
428
  # Convert to json and back to attain json types
@@ -508,17 +514,21 @@ class Config:
508
514
  results.update(json_data)
509
515
  return results
510
516
 
511
- def get(self, name: str) -> Any:
517
+ def get(self, name: str, default: Any = None) -> Any:
512
518
  """Access a configuration value, falling back to the default value if unset.
513
519
 
514
520
  Args:
515
521
  name: The name of the attribute to access.
522
+ default: The value to return if the name is not found
516
523
 
517
524
  Returns:
518
525
  The configuration variable value.
519
526
 
520
527
  """
521
- return self.settings[name].value
528
+ if name in self.settings:
529
+ return self.settings[name].value
530
+ else:
531
+ return default
522
532
 
523
533
  def get_item(self, name: str) -> Any:
524
534
  """Access a configuration item.
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import logging
6
6
  import mimetypes
7
- from functools import partial
7
+ from functools import lru_cache, partial
8
8
  from typing import TYPE_CHECKING, NamedTuple
9
9
 
10
10
  from prompt_toolkit.cache import FastDictCache, SimpleCache
@@ -12,7 +12,10 @@ from prompt_toolkit.filters import to_filter
12
12
  from upath import UPath
13
13
  from upath.implementations.http import HTTPPath
14
14
 
15
+ from euporie.core.path import DataPath
16
+
15
17
  if TYPE_CHECKING:
18
+ from pathlib import Path
16
19
  from typing import Any, Callable, Iterable
17
20
 
18
21
  from prompt_toolkit.filters import Filter, FilterOrBool
@@ -44,19 +47,71 @@ ERROR_OUTPUTS = {
44
47
  "formatted_text": [("fg:white bg:darkred", "(Format Conversion Error)")],
45
48
  }
46
49
 
47
- # def get_mime(path: "UPath") -> "str":
48
- # If http, do HEAD request and check "content-type" header
49
- # If data:// uri, mime is given in the path
50
- # If file has extension, guess from filename
51
- # Else try using python-magic
52
- # >>> import magic
53
- # >>> magic.from_buffer(open("testdata/test.pdf", "rb").read(2048))
54
- # 'PDF document, version 1.2'
55
- # >>> magic.from_file("testdata/test.pdf", mime=True)
56
- # 'application/pdf'
50
+
51
+ @lru_cache
52
+ def get_mime(path: Path | str) -> str | None:
53
+ """Attempt to determine the mime-type of a path."""
54
+ if isinstance(path, str):
55
+ path = UPath(path)
56
+ try:
57
+ path = path.resolve()
58
+ except Exception:
59
+ log.debug("Cannot resolve '%s'", path)
60
+
61
+ mime = None
62
+
63
+ # Read from path of data URI
64
+ if isinstance(path, DataPath):
65
+ mime = path._mime
66
+
67
+ # Guess from file-extension
68
+ if not mime and path.suffix:
69
+ # Check for Jupyter notebooks by extension
70
+ if path.suffix == ".ipynb":
71
+ return "application/x-ipynb+json"
72
+ else:
73
+ mime, _ = mimetypes.guess_type(path)
74
+
75
+ # Try using magic
76
+ if not mime:
77
+ try:
78
+ import magic
79
+ except ModuleNotFoundError:
80
+ pass
81
+ else:
82
+ with path.open(mode="rb") as f:
83
+ mime = magic.from_buffer(f.read(2048), mime=True)
84
+
85
+ # If we have a web-address, nsure we have a url
86
+ # Check http-headers and nsure we have a url
87
+ if not mime and isinstance(path, HTTPPath) and path._url is not None:
88
+ from fsspec.asyn import sync
89
+
90
+ # Get parsed url
91
+ url = path._url.geturl()
92
+ # Get the fsspec fs
93
+ fs = path._accessor._fs
94
+ # Ensure we have a session
95
+ session = sync(fs.loop, fs.set_session)
96
+ # Use HEAD requests if the server allows it, falling back to GETs
97
+ for method in (session.head, session.get):
98
+ r = sync(fs.loop, method, url, allow_redirects=True)
99
+ try:
100
+ r.raise_for_status()
101
+ except Exception:
102
+ log.debug("Request failed: %s", r)
103
+ continue
104
+ else:
105
+ content_type = r.headers.get("Content-Type")
106
+ if content_type is not None:
107
+ mime = content_type.partition(";")[0]
108
+ break
109
+
110
+ return mime
57
111
 
58
112
 
59
- def get_format(path: UPath | str, default: str = "") -> str:
113
+ @lru_cache
114
+ def get_format(path: Path | str, default: str = "") -> str:
60
115
  """Attempt to guess the format of a path."""
61
116
  if isinstance(path, str):
62
117
  path = UPath(path)
@@ -65,7 +120,7 @@ def get_format(path: UPath | str, default: str = "") -> str:
65
120
  default = "html"
66
121
  else:
67
122
  default = "ansi"
68
- mime, _ = mimetypes.guess_type(path)
123
+ mime = get_mime(path)
69
124
  return MIME_FORMATS.get(mime, default) if mime else default
70
125
 
71
126
 
@@ -144,7 +199,7 @@ def find_route(from_: str, to: str) -> list | None:
144
199
  for step_a, step_b in zip(chain, chain[1:])
145
200
  ]
146
201
  ),
147
- )[0]
202
+ )
148
203
  else:
149
204
  return None
150
205
 
@@ -162,7 +217,7 @@ def convert(
162
217
  rows: int | None = None,
163
218
  fg: str | None = None,
164
219
  bg: str | None = None,
165
- path: UPath | None = None,
220
+ path: Path | None = None,
166
221
  ) -> Any:
167
222
  """Convert between formats."""
168
223
  try:
@@ -179,49 +234,55 @@ def convert(
179
234
  rows: int | None = None,
180
235
  fg: str | None = None,
181
236
  bg: str | None = None,
182
- path: UPath | None = None,
237
+ path: Path | None = None,
183
238
  ) -> Any:
184
239
  if from_ == to:
185
240
  return data
186
- route = _CONVERTOR_ROUTE_CACHE[(from_, to)]
187
- # log.debug("Converting from '%s' to '%s' using route: %s", from_, to, route)
188
- if route is None:
241
+ routes = _CONVERTOR_ROUTE_CACHE[(from_, to)]
242
+ log.debug("Converting from '%s' to '%s' using route: %s", from_, to, routes)
243
+ if not routes:
189
244
  raise NotImplementedError(f"Cannot convert from `{from_}` to `{to}`")
190
245
  output: Any = data
191
- for stage_a, stage_b in zip(route, route[1:]):
192
- # Find converter with lowest weight
193
- func = sorted(
194
- [
195
- conv
196
- for conv in converters[stage_b][stage_a]
197
- if _FILTER_CACHE.get((conv,), conv.filter_)
198
- ],
199
- key=lambda x: x.weight,
200
- )[0].func
201
- # Add intermediate steps to the cache
202
- try:
203
- output_hash = hash(data)
204
- except TypeError as error:
205
- log.warning("Cannot hash %s", data)
206
- raise error
207
- try:
208
- output = _CONVERSION_CACHE.get(
209
- (output_hash, from_, stage_b, cols, rows, fg, bg, path),
210
- partial(func, output, cols, rows, fg, bg, path),
211
- )
212
- except Exception:
213
- output = None
214
- if output is None:
215
- log.error(
216
- "Failed to convert `%s` from `%s`"
217
- " to `%s` using route `%s` at stage `%s`",
218
- data.__repr__()[:10],
219
- from_,
220
- to,
221
- route,
222
- stage_b,
223
- )
224
- output = ERROR_OUTPUTS.get(to, b"(Conversion Error)")
246
+ for route in routes:
247
+ for stage_a, stage_b in zip(route, route[1:]):
248
+ # Find converter with lowest weight
249
+ func = sorted(
250
+ [
251
+ conv
252
+ for conv in converters[stage_b][stage_a]
253
+ if _FILTER_CACHE.get((conv,), conv.filter_)
254
+ ],
255
+ key=lambda x: x.weight,
256
+ )[0].func
257
+ # Add intermediate steps to the cache
258
+ try:
259
+ output_hash = hash(data)
260
+ except TypeError as error:
261
+ log.warning("Cannot hash %s", data)
262
+ raise error
263
+ try:
264
+ output = _CONVERSION_CACHE.get(
265
+ (output_hash, from_, stage_b, cols, rows, fg, bg, path),
266
+ partial(func, output, cols, rows, fg, bg, path),
267
+ )
268
+ except Exception:
269
+ log.exception("An error occurred during format conversion")
270
+ output = None
271
+ if output is None:
272
+ log.error(
273
+ "Failed to convert `%s` from `%s`"
274
+ " to `%s` using route `%s` at stage `%s`",
275
+ data.__repr__()[:10],
276
+ from_,
277
+ to,
278
+ route,
279
+ stage_b,
280
+ )
281
+ # Try the next route on error
282
+ break
283
+ else:
284
+ # If this route succeeded, stop trying routes
285
+ break
225
286
  return output
226
287
 
227
288
  data = _CONVERSION_CACHE.get(
@@ -229,4 +290,7 @@ def convert(
229
290
  partial(_convert, data, from_, to, cols, rows, fg, bg, path),
230
291
  )
231
292
 
293
+ if data is None:
294
+ data = ERROR_OUTPUTS.get(to, b"(Conversion Error)")
295
+
232
296
  return data