euporie 2.8.5__py3-none-any.whl → 2.8.6__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 (38) hide show
  1. euporie/core/__init__.py +1 -1
  2. euporie/core/__main__.py +2 -2
  3. euporie/core/_settings.py +7 -2
  4. euporie/core/app/_commands.py +26 -1
  5. euporie/core/app/_settings.py +34 -4
  6. euporie/core/app/app.py +18 -11
  7. euporie/core/bars/command.py +44 -21
  8. euporie/core/commands.py +0 -16
  9. euporie/core/filters.py +32 -9
  10. euporie/core/format.py +2 -3
  11. euporie/core/graphics.py +191 -31
  12. euporie/core/layout/scroll.py +35 -33
  13. euporie/core/log.py +1 -4
  14. euporie/core/path.py +61 -13
  15. euporie/core/tabs/__init__.py +2 -4
  16. euporie/core/tabs/base.py +73 -7
  17. euporie/core/tabs/kernel.py +2 -3
  18. euporie/core/tabs/notebook.py +14 -54
  19. euporie/core/utils.py +1 -18
  20. euporie/core/widgets/cell.py +1 -1
  21. euporie/core/widgets/dialog.py +32 -3
  22. euporie/core/widgets/display.py +2 -2
  23. euporie/core/widgets/menu.py +1 -1
  24. euporie/notebook/tabs/display.py +2 -2
  25. euporie/notebook/tabs/edit.py +8 -43
  26. euporie/notebook/tabs/json.py +2 -2
  27. euporie/web/__init__.py +1 -0
  28. euporie/web/tabs/__init__.py +14 -0
  29. euporie/web/tabs/web.py +10 -4
  30. euporie/web/widgets/__init__.py +1 -0
  31. euporie/web/widgets/webview.py +2 -4
  32. {euporie-2.8.5.dist-info → euporie-2.8.6.dist-info}/METADATA +4 -2
  33. {euporie-2.8.5.dist-info → euporie-2.8.6.dist-info}/RECORD +38 -35
  34. {euporie-2.8.5.data → euporie-2.8.6.data}/data/share/applications/euporie-console.desktop +0 -0
  35. {euporie-2.8.5.data → euporie-2.8.6.data}/data/share/applications/euporie-notebook.desktop +0 -0
  36. {euporie-2.8.5.dist-info → euporie-2.8.6.dist-info}/WHEEL +0 -0
  37. {euporie-2.8.5.dist-info → euporie-2.8.6.dist-info}/entry_points.txt +0 -0
  38. {euporie-2.8.5.dist-info → euporie-2.8.6.dist-info}/licenses/LICENSE +0 -0
euporie/core/graphics.py CHANGED
@@ -376,8 +376,8 @@ class ItermGraphicControl(GraphicControl):
376
376
  return self._format_cache.get(key, render_lines)
377
377
 
378
378
 
379
- class KittyGraphicControl(GraphicControl):
380
- """A graphic control which displays images using Kitty's graphics protocol."""
379
+ class BaseKittyGraphicControl(GraphicControl):
380
+ """Base graphic control with common methods for both styles of kitty display."""
381
381
 
382
382
  _kitty_image_count: ClassVar[int] = 1
383
383
 
@@ -451,7 +451,7 @@ class KittyGraphicControl(GraphicControl):
451
451
  BoundedWritePosition(0, 0, width=cols, height=rows, bbox=bbox)
452
452
  )
453
453
  self.kitty_image_id = self._kitty_image_count
454
- KittyGraphicControl._kitty_image_count += 1
454
+ self.__class__._kitty_image_count += 1
455
455
 
456
456
  while data:
457
457
  chunk, data = data[:4096], data[4096:]
@@ -471,24 +471,6 @@ class KittyGraphicControl(GraphicControl):
471
471
  self.app.output.flush()
472
472
  self.loaded = True
473
473
 
474
- def hide_cmd(self) -> str:
475
- """Generate a command to hide the graphic."""
476
- return passthrough(
477
- self._kitty_cmd(
478
- a="d",
479
- d="i",
480
- i=self.kitty_image_id,
481
- q=1,
482
- ),
483
- self.app.config,
484
- )
485
-
486
- def hide(self) -> None:
487
- """Hide the graphic from show without deleting it."""
488
- if self.kitty_image_id > 0:
489
- self.app.output.write_raw(self.hide_cmd())
490
- self.app.output.flush()
491
-
492
474
  def delete(self) -> None:
493
475
  """Delete the graphic from the terminal."""
494
476
  if self.kitty_image_id > 0:
@@ -506,6 +488,22 @@ class KittyGraphicControl(GraphicControl):
506
488
  self.app.output.flush()
507
489
  self.loaded = False
508
490
 
491
+ def reset(self) -> None:
492
+ """Hide and delete the kitty graphic from the terminal."""
493
+ self.hide()
494
+ self.delete()
495
+ super().reset()
496
+
497
+ def close(self) -> None:
498
+ """Remove the displayed object entirely."""
499
+ super().close()
500
+ if not self.app.leave_graphics():
501
+ self.delete()
502
+
503
+
504
+ class KittyGraphicControl(BaseKittyGraphicControl):
505
+ """A graphic control which displays images using Kitty's graphics protocol."""
506
+
509
507
  def get_rendered_lines(
510
508
  self, visible_width: int, visible_height: int, wrap_lines: bool = False
511
509
  ) -> list[StyleAndTextTuples]:
@@ -616,17 +614,173 @@ class KittyGraphicControl(GraphicControl):
616
614
  )
617
615
  return self._format_cache.get(key, render_lines)
618
616
 
619
- def reset(self) -> None:
620
- """Hide and delete the kitty graphic from the terminal."""
621
- self.hide()
622
- self.delete()
623
- super().reset()
617
+ def hide_cmd(self) -> str:
618
+ """Generate a command to hide the graphic."""
619
+ return passthrough(
620
+ self._kitty_cmd(
621
+ a="d",
622
+ d="i",
623
+ i=self.kitty_image_id,
624
+ q=1,
625
+ ),
626
+ self.app.config,
627
+ )
628
+
629
+ def hide(self) -> None:
630
+ """Hide the graphic from show without deleting it."""
631
+ if self.kitty_image_id > 0:
632
+ self.app.output.write_raw(self.hide_cmd())
633
+ self.app.output.flush()
624
634
 
625
- def close(self) -> None:
626
- """Remove the displayed object entirely."""
627
- super().close()
628
- if not self.app.leave_graphics():
629
- self.delete()
635
+
636
+ class KittyUnicodeGraphicControl(BaseKittyGraphicControl):
637
+ """A graphic control which displays images using Kitty's Unicode placeholder mechanism."""
638
+
639
+ PLACEHOLDER = "\U0010eeee" # U+10EEEE placeholder character
640
+ # fmt: off
641
+ DIACRITICS = ( # Diacritics for encoding row/column numbers (0-9)
642
+ "\u0305", "\u030d", "\u030e", "\u0310", "\u0312", "\u033d", "\u033e", "\u033f",
643
+ "\u0346", "\u034a", "\u034b", "\u034c", "\u0350", "\u0351", "\u0352", "\u0357",
644
+ "\u035b", "\u0363", "\u0364", "\u0365", "\u0366", "\u0367", "\u0368", "\u0369",
645
+ "\u036a", "\u036b", "\u036c", "\u036d", "\u036e", "\u036f", "\u0483", "\u0484",
646
+ "\u0485", "\u0486", "\u0487", "\u0592", "\u0593", "\u0594", "\u0595", "\u0597",
647
+ "\u0598", "\u0599", "\u059c", "\u059d", "\u059e", "\u059f", "\u05a0", "\u05a1",
648
+ "\u05a8", "\u05a9", "\u05ab", "\u05ac", "\u05af", "\u05c4", "\u0610", "\u0611",
649
+ "\u0612", "\u0613", "\u0614", "\u0615", "\u0616", "\u0617", "\u0657", "\u0658",
650
+ "\u0659", "\u065a", "\u065b", "\u065d", "\u065e", "\u06d6", "\u06d7", "\u06d8",
651
+ "\u06d9", "\u06da", "\u06db", "\u06dc", "\u06df", "\u06e0", "\u06e1", "\u06e2",
652
+ "\u06e4", "\u06e7", "\u06e8", "\u06eb", "\u06ec", "\u0730", "\u0732", "\u0733",
653
+ "\u0735", "\u0736", "\u073a", "\u073d", "\u073f", "\u0740", "\u0741", "\u0743",
654
+ "\u0745", "\u0747", "\u0749", "\u074a", "\u07eb", "\u07ec", "\u07ed", "\u07ee",
655
+ "\u07ef", "\u07f0", "\u07f1", "\u07f3", "\u0816", "\u0817", "\u0818", "\u0819",
656
+ "\u081b", "\u081c", "\u081d", "\u081e", "\u081f", "\u0820", "\u0821", "\u0822",
657
+ "\u0823", "\u0825", "\u0826", "\u0827", "\u0829", "\u082a", "\u082b", "\u082c",
658
+ "\u082d", "\u0951", "\u0953", "\u0954", "\u0f82", "\u0f83", "\u0f86", "\u0f87",
659
+ "\u135d", "\u135e", "\u135f", "\u17dd", "\u193a", "\u1a17", "\u1a75", "\u1a76",
660
+ "\u1a77", "\u1a78", "\u1a79", "\u1a7a", "\u1a7b", "\u1a7c", "\u1b6b", "\u1b6d",
661
+ "\u1b6e", "\u1b6f", "\u1b70", "\u1b71", "\u1b72", "\u1b73", "\u1cd0", "\u1cd1",
662
+ "\u1cd2", "\u1cda", "\u1cdb", "\u1ce0", "\u1dc0", "\u1dc1", "\u1dc3", "\u1dc4",
663
+ "\u1dc5", "\u1dc6", "\u1dc7", "\u1dc8", "\u1dc9", "\u1dcb", "\u1dcc", "\u1dd1",
664
+ "\u1dd2", "\u1dd3", "\u1dd4", "\u1dd5", "\u1dd6", "\u1dd7", "\u1dd8", "\u1dd9",
665
+ "\u1dda", "\u1ddb", "\u1ddc", "\u1ddd", "\u1dde", "\u1ddf", "\u1de0", "\u1de1",
666
+ "\u1de2", "\u1de3", "\u1de4", "\u1de5", "\u1de6", "\u1dfe", "\u20d0", "\u20d1",
667
+ "\u20d4", "\u20d5", "\u20d6", "\u20d7", "\u20db", "\u20dc", "\u20e1", "\u20e7",
668
+ "\u20e9", "\u20f0", "\u2cef", "\u2cf0", "\u2cf1", "\u2de0", "\u2de1", "\u2de2",
669
+ "\u2de3", "\u2de4", "\u2de5", "\u2de6", "\u2de7", "\u2de8", "\u2de9", "\u2dea",
670
+ "\u2deb", "\u2dec", "\u2ded", "\u2dee", "\u2def", "\u2df0", "\u2df1", "\u2df2",
671
+ "\u2df3", "\u2df4", "\u2df5", "\u2df6", "\u2df7", "\u2df8", "\u2df9", "\u2dfa",
672
+ "\u2dfb", "\u2dfc", "\u2dfd", "\u2dfe", "\u2dff", "\ua66f", "\ua67c", "\ua67d",
673
+ "\ua6f0", "\ua6f1", "\ua8e0", "\ua8e1", "\ua8e2", "\ua8e3", "\ua8e4", "\ua8e5",
674
+ "\ua8e6", "\ua8e7", "\ua8e8", "\ua8e9", "\ua8ea", "\ua8eb", "\ua8ec", "\ua8ed",
675
+ "\ua8ee", "\ua8ef", "\ua8f0", "\ua8f1", "\uaab0", "\uaab2", "\uaab3", "\uaab7",
676
+ "\uaab8", "\uaabe", "\uaabf", "\uaac1", "\ufe20", "\ufe21", "\ufe22", "\ufe23",
677
+ "\ufe24", "\ufe25", "\ufe26",
678
+ "\U00010a0f", "\U00010a38", "\U0001d185", "\U0001d186", "\U0001d187",
679
+ "\U0001d188", "\U0001d189", "\U0001d1aa", "\U0001d1ab", "\U0001d1ac",
680
+ "\U0001d1ad", "\U0001d242", "\U0001d243", "\U0001d244",
681
+ )
682
+ # fmt: on
683
+
684
+ def __init__(
685
+ self,
686
+ datum: Datum,
687
+ scale: float = 0,
688
+ bbox: DiInt | None = None,
689
+ ) -> None:
690
+ """Create a new kitty graphic instance."""
691
+ super().__init__(datum, scale, bbox)
692
+ self.placements: set[tuple[int, int]] = set()
693
+
694
+ def get_rendered_lines(
695
+ self, visible_width: int, visible_height: int, wrap_lines: bool = False
696
+ ) -> list[StyleAndTextTuples]:
697
+ """Get rendered lines from the cache, or generate them."""
698
+ bbox = self.bbox
699
+
700
+ cell_size_px = self.app.cell_size_px
701
+ datum = self._datum_pad_cache[(self.datum, *cell_size_px)]
702
+ px, py = datum.pixel_size()
703
+ # Fall back to a default pixel size
704
+ px = px or 100
705
+ py = py or 100
706
+
707
+ d_cols, d_aspect = datum.cell_size()
708
+ d_rows = d_cols * d_aspect
709
+
710
+ total_available_width = visible_width + bbox.left + bbox.right
711
+ total_available_height = visible_height + bbox.top + bbox.bottom
712
+
713
+ # Scale down the graphic to fit in the available space
714
+ if d_rows > total_available_height or d_cols > total_available_width:
715
+ if d_rows / total_available_height > d_cols / total_available_width:
716
+ ratio = min(1, total_available_height / d_rows)
717
+ else:
718
+ ratio = min(1, total_available_width / d_cols)
719
+ else:
720
+ ratio = 1
721
+
722
+ # Calculate the size and cropping bbox at which we want to display the graphic
723
+ cols = floor(d_cols * ratio)
724
+ rows = ceil(cols * d_aspect)
725
+ if not self.loaded:
726
+ self.load(cols=cols, rows=rows, bbox=DiInt(0, 0, 0, 0))
727
+
728
+ # Add virtual placement for this size if required
729
+ if (cols, rows) not in self.placements:
730
+ cmd = self._kitty_cmd(
731
+ a="p", # Display a previously transmitted image
732
+ i=self.kitty_image_id,
733
+ p=1, # Placement ID
734
+ U=1, # Create a virtual placement
735
+ c=cols,
736
+ r=rows,
737
+ q=2,
738
+ )
739
+ self.app.output.write_raw(passthrough(cmd, self.app.config))
740
+ self.app.output.flush()
741
+ self.placements.add((cols, rows))
742
+
743
+ def render_lines() -> list[StyleAndTextTuples]:
744
+ """Render the lines to display in the control."""
745
+ ft: StyleAndTextTuples = []
746
+
747
+ # Generate placeholder grid
748
+ col_start = bbox.left
749
+ col_stop = cols - bbox.right
750
+ placeholder = self.PLACEHOLDER
751
+ diacritics = self.DIACRITICS
752
+ for row in range(bbox.top, rows - bbox.bottom):
753
+ for col in range(col_start, col_stop):
754
+ ft.extend(
755
+ [
756
+ # We set the ptk-color for the last column so the renderer
757
+ # knows to change the color back after this gets rendered.
758
+ ("fg:#888" if col == col_stop - 1 else "", " "),
759
+ (
760
+ "[ZeroWidthEscape]",
761
+ # We move the cursor back a cell before writing the
762
+ # kitty unicode char using a ZWE
763
+ "\b"
764
+ # Set the kitty graphic and placement we want to render
765
+ # by manually setting an 8-bit foregroun color.
766
+ # The placement ID is set to 1 using underline color.
767
+ f"\x1b[38;5;{self.kitty_image_id}m\x1b[58;1m"
768
+ # Writing the unicode char moves the cursor forward
769
+ # again to where the renderer expects it to be
770
+ f"{placeholder}{diacritics[row]}{diacritics[col]}",
771
+ ),
772
+ ]
773
+ )
774
+ ft.append(("", "\n"))
775
+ return list(split_lines(ft))
776
+
777
+ key = (
778
+ visible_width,
779
+ self.app.color_palette,
780
+ self.app.cell_size_px,
781
+ bbox,
782
+ )
783
+ return self._format_cache.get(key, render_lines)
630
784
 
631
785
 
632
786
  class NotVisible(Exception):
@@ -767,11 +921,17 @@ def select_graphic_control(format_: str) -> type[GraphicControl] | None:
767
921
  and (not _in_mplex or (_in_mplex and force_graphics))
768
922
  ):
769
923
  useable_graphics_controls.append(KittyGraphicControl)
924
+ useable_graphics_controls.append(KittyUnicodeGraphicControl)
770
925
  if (
771
926
  preferred_graphics_protocol == "kitty"
772
927
  and KittyGraphicControl in useable_graphics_controls
773
928
  ):
774
929
  SelectedGraphicControl = KittyGraphicControl
930
+ elif (
931
+ preferred_graphics_protocol == "kitty-unicode"
932
+ and KittyUnicodeGraphicControl in useable_graphics_controls
933
+ ):
934
+ SelectedGraphicControl = KittyUnicodeGraphicControl
775
935
  # Tmux now supports sixels (>=3.4)
776
936
  elif (app.term_graphics_sixel or force_graphics) and find_route(
777
937
  format_, "sixel"
@@ -1,7 +1,8 @@
1
- """Contains containers which display children at full height vertially stacked."""
1
+ """Contains containers which display children at full height vertically stacked."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import logging
6
7
  from typing import TYPE_CHECKING, cast
7
8
 
@@ -20,7 +21,6 @@ from prompt_toolkit.mouse_events import MouseEvent, MouseEventType, MouseModifie
20
21
 
21
22
  from euporie.core.layout.cache import CachedContainer
22
23
  from euporie.core.layout.screen import BoundedWritePosition
23
- from euporie.core.utils import run_in_thread_with_context
24
24
 
25
25
  if TYPE_CHECKING:
26
26
  from collections.abc import Sequence
@@ -68,7 +68,7 @@ class ScrollingContainer(Container):
68
68
  self._child_cache: dict[int, CachedContainer] = {}
69
69
  self._children: list[CachedContainer] = []
70
70
  self.refresh_children = True
71
- self.pre_rendered = 0.0
71
+ self.pre_rendered: float | None = None
72
72
 
73
73
  self._selected_slice = slice(
74
74
  0, 1
@@ -92,23 +92,33 @@ class ScrollingContainer(Container):
92
92
 
93
93
  def pre_render_children(self, width: int, height: int) -> None:
94
94
  """Render all unrendered children in a background thread."""
95
+ self.pre_rendered = 0.0
96
+ children = self.all_children()
97
+ incr = 1 / len(children)
98
+ app = get_app()
99
+
100
+ def _cb(task: asyncio.Task) -> None:
101
+ """Task callback to update pre-rendering percentage."""
102
+ assert isinstance(self.pre_rendered, float)
103
+ self.pre_rendered += incr
104
+ app.invalidate()
95
105
 
96
- def _render_in_thread() -> None:
97
- """Render children in thread."""
98
- children = self.all_children()
99
- n_children = len(children)
100
- app = get_app()
101
- for i, child in enumerate(children):
102
- if not app._is_running:
103
- return
104
- if isinstance(child, CachedContainer):
105
- child.render(width, height)
106
- self.pre_rendered = i / n_children
107
- app.invalidate()
106
+ tasks = set()
107
+ for child in children:
108
+ if isinstance(child, CachedContainer):
109
+ task = app.create_background_task(
110
+ asyncio.to_thread(child.render, width, height)
111
+ )
112
+ task.add_done_callback(_cb)
113
+ tasks.add(task)
114
+
115
+ async def _finish() -> None:
116
+ await asyncio.gather(*tasks)
108
117
  self.pre_rendered = 1.0
109
118
  app.invalidate()
119
+ # app.exit()
110
120
 
111
- run_in_thread_with_context(_render_in_thread)
121
+ app.create_background_task(_finish())
112
122
 
113
123
  def reset(self) -> None:
114
124
  """Reset the state of this container and all the children."""
@@ -237,7 +247,6 @@ class ScrollingContainer(Container):
237
247
  :py:const:`None`
238
248
 
239
249
  """
240
- # self.refresh_children = True
241
250
  if n > 0:
242
251
  if (
243
252
  min(self.visible_indices) == 0
@@ -255,8 +264,8 @@ class ScrollingContainer(Container):
255
264
  if bottom_pos is not None:
256
265
  n = max(
257
266
  n,
258
- self.last_write_position.height
259
- - (bottom_pos + bottom_child.height + self.scrolling),
267
+ (bottom_pos + bottom_child.height + self.scrolling)
268
+ - self.last_write_position.height,
260
269
  )
261
270
  if (
262
271
  bottom_pos + bottom_child.height + self.scrolling + n
@@ -365,9 +374,6 @@ class ScrollingContainer(Container):
365
374
  # Record children which are currently visible
366
375
  visible_indices = set()
367
376
 
368
- # Ensure we have the right children
369
- all_children = self.all_children()
370
-
371
377
  # Force the selected children to refresh
372
378
  selected_indices = self.selected_indices
373
379
  self._selected_children: list[CachedContainer] = []
@@ -549,19 +555,10 @@ class ScrollingContainer(Container):
549
555
  # are partially obscured
550
556
  self.last_write_position = write_position
551
557
 
552
- # Calculate scrollbar info
553
- sizes = self.known_sizes
554
- avg_size = sum(sizes.values()) / len(sizes) if sizes else 0
555
- n_children = len(all_children)
556
- for i in range(n_children):
557
- if i not in sizes:
558
- sizes[i] = int(avg_size)
559
- content_height = max(sum(sizes.values()), 1)
560
-
561
558
  # Mock up a WindowRenderInfo so we can draw a scrollbar margin
562
559
  self.render_info = WindowRenderInfo(
563
560
  window=cast("Window", self),
564
- ui_content=UIContent(line_count=content_height),
561
+ ui_content=UIContent(line_count=max(sum(self.known_sizes.values()), 1)),
565
562
  horizontal_scroll=0,
566
563
  vertical_scroll=self.vertical_scroll,
567
564
  window_width=available_width,
@@ -577,7 +574,7 @@ class ScrollingContainer(Container):
577
574
  self.scrolling = 0
578
575
 
579
576
  # Trigger pre-rendering of children
580
- if not self.pre_rendered:
577
+ if self.pre_rendered is None:
581
578
  self.pre_render_children(available_width, available_height)
582
579
 
583
580
  @property
@@ -730,9 +727,14 @@ class ScrollingContainer(Container):
730
727
  def known_sizes(self) -> dict[int, int]:
731
728
  """A dictionary mapping child indices to height values."""
732
729
  sizes = {}
730
+ missing = set()
733
731
  for i, child in enumerate(self._children):
734
732
  if isinstance(child, CachedContainer) and child.height:
735
733
  sizes[i] = child.height
734
+ else:
735
+ missing.add(i)
736
+ avg = int(sum(sizes.values()) / len(sizes))
737
+ sizes.update(dict.fromkeys(missing, avg))
736
738
  return sizes
737
739
 
738
740
  def _scroll_up(self) -> None:
euporie/core/log.py CHANGED
@@ -506,10 +506,7 @@ def setup_logs(config: Config | None = None) -> None:
506
506
 
507
507
  # Update log_config based on additional config dict provided
508
508
  if config.log_config:
509
- import json
510
-
511
- extra_config = json.loads(config.log_config)
512
- dict_merge(log_config, extra_config)
509
+ dict_merge(log_config, config.log_config)
513
510
 
514
511
  # Configure the logger
515
512
  # Pytype used TypedDicts to validate the dictionary structure, but I cannot get
euporie/core/path.py CHANGED
@@ -9,36 +9,72 @@ from typing import TYPE_CHECKING
9
9
  import upath
10
10
  from aiohttp.client_reqrep import ClientResponse
11
11
  from fsspec.implementations.http import HTTPFileSystem as FsHTTPFileSystem
12
+ from fsspec.implementations.http import get_client
12
13
  from fsspec.registry import register_implementation as fs_register_implementation
13
14
  from upath import UPath
14
15
 
15
16
  if TYPE_CHECKING:
17
+ import asyncio
16
18
  from collections.abc import Mapping
17
19
  from os import PathLike
18
- from typing import Any
20
+ from typing import Any, Callable
19
21
 
20
-
21
- log = logging.getLogger(__name__)
22
-
23
-
24
- # Monkey-patch `aiohttp` to not raise exceptions on non-200 responses
22
+ import aiohttp
25
23
 
26
24
 
27
- def _raise_for_status(self: ClientResponse) -> None:
28
- """Monkey-patch :py:class:`aiohttp.ClientResponse` not to raise for any status."""
29
-
25
+ log = logging.getLogger(__name__)
30
26
 
31
- setattr(ClientResponse, "raise_for_status", _raise_for_status) # noqa B010
32
27
 
28
+ class NoRaiseClientResponse(ClientResponse):
29
+ """An ``aiohttp`` client response which does not raise on >=400 status responses."""
33
30
 
34
- # Define and register non-raising HTTP filesystem implementation for fsspec
31
+ @property
32
+ def ok(self) -> bool:
33
+ """Returns ``True`` if ``status`` is probably renderable."""
34
+ return self.status not in {405}
35
35
 
36
36
 
37
37
  class HTTPFileSystem(FsHTTPFileSystem):
38
- """A :py:class:`HTTPFileSystem` which does not raise exceptions on 404 errors."""
38
+ """A HTTP filesystem implementation which does not raise on errors."""
39
+
40
+ def __init__(
41
+ self,
42
+ simple_links: bool = True,
43
+ block_size: int | None = None,
44
+ same_scheme: bool = True,
45
+ size_policy: None = None,
46
+ cache_type: str = "bytes",
47
+ cache_options: dict[str, Any] | None = None,
48
+ asynchronous: bool = False,
49
+ loop: asyncio.AbstractEventLoop | None = None,
50
+ client_kwargs: dict[str, Any] | None = None,
51
+ get_client: Callable[..., aiohttp.ClientSession] = get_client,
52
+ encoded: bool = False,
53
+ **storage_options: Any,
54
+ ) -> None:
55
+ """Defaults to using :py:mod:`NoRaiseClientResponse` for responses."""
56
+ client_kwargs = {
57
+ "response_class": NoRaiseClientResponse,
58
+ **(client_kwargs or {}),
59
+ }
60
+ super().__init__(
61
+ simple_links=simple_links,
62
+ block_size=block_size,
63
+ same_scheme=same_scheme,
64
+ size_policy=size_policy,
65
+ cache_type=cache_type,
66
+ cache_options=cache_options,
67
+ asynchronous=asynchronous,
68
+ loop=loop,
69
+ client_kwargs=client_kwargs,
70
+ get_client=get_client,
71
+ encoded=encoded,
72
+ **storage_options,
73
+ )
39
74
 
40
75
  def _raise_not_found_for_status(self, response: ClientResponse, url: str) -> None:
41
76
  """Do not raise an exception for 404 errors."""
77
+ response.raise_for_status()
42
78
 
43
79
 
44
80
  fs_register_implementation("http", HTTPFileSystem, clobber=True)
@@ -63,8 +99,15 @@ class UntitledPath(upath.core.UPath):
63
99
  return False
64
100
 
65
101
 
66
- def parse_path(path: str | PathLike, resolve: bool = True) -> Path:
102
+ def parse_path(path: str | PathLike, resolve: bool | None = None) -> Path:
67
103
  """Parse and resolve a path."""
104
+ if resolve is None:
105
+ from upath.implementations.http import HTTPPath
106
+
107
+ if isinstance(path, HTTPPath):
108
+ resolve = True
109
+ else:
110
+ resolve = False
68
111
  if not isinstance(path, Path):
69
112
  path = UPath(path)
70
113
  try:
@@ -76,4 +119,9 @@ def parse_path(path: str | PathLike, resolve: bool = True) -> Path:
76
119
  path = path.resolve()
77
120
  except (AttributeError, NotImplementedError, Exception):
78
121
  log.info("Path %s not resolvable", path)
122
+ else:
123
+ try:
124
+ path = path.absolute()
125
+ except NotImplementedError:
126
+ pass
79
127
  return path
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from dataclasses import dataclass, field
6
- from importlib import import_module
6
+ from pkgutil import resolve_name
7
7
  from typing import TYPE_CHECKING
8
8
 
9
9
  if TYPE_CHECKING:
@@ -23,9 +23,7 @@ class TabRegistryEntry:
23
23
  @property
24
24
  def tab_class(self) -> type[Tab]:
25
25
  """Import and return the tab class."""
26
- module_path, _, attribute = self.path.partition(":")
27
- module = import_module(module_path)
28
- return getattr(module, attribute)
26
+ return resolve_name(self.path)
29
27
 
30
28
  def __lt__(self, other: TabRegistryEntry) -> bool:
31
29
  """Sort by weight."""
euporie/core/tabs/base.py CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import logging
6
7
  from abc import ABCMeta
7
8
  from typing import TYPE_CHECKING, ClassVar
@@ -13,13 +14,12 @@ from upath import UPath
13
14
 
14
15
  from euporie.core.app.current import get_app
15
16
  from euporie.core.commands import add_cmd
16
- from euporie.core.filters import tab_has_focus
17
+ from euporie.core.filters import tab_can_save, tab_has_focus
17
18
  from euporie.core.key_binding.registry import (
18
19
  register_bindings,
19
20
  )
20
21
  from euporie.core.layout.containers import Window
21
- from euporie.core.path import parse_path
22
- from euporie.core.utils import run_in_thread_with_context
22
+ from euporie.core.path import UntitledPath, parse_path
23
23
 
24
24
  if TYPE_CHECKING:
25
25
  from pathlib import Path
@@ -107,11 +107,77 @@ class Tab(metaclass=ABCMeta):
107
107
  cb()
108
108
  self.after_save.fire()
109
109
 
110
- run_in_thread_with_context(self.save, path, _wrapped_cb)
110
+ self.app.create_background_task(asyncio.to_thread(self.save, path, _wrapped_cb))
111
111
 
112
112
  def save(self, path: Path | None = None, cb: Callable | None = None) -> None:
113
- """Save the current tab."""
114
- raise NotImplementedError
113
+ """Save the current file."""
114
+ if path is not None:
115
+ self.path = path
116
+
117
+ if (self.path is None or isinstance(self.path, UntitledPath)) and (
118
+ dialog := self.app.dialogs.get("save-as")
119
+ ):
120
+ dialog.show(tab=self, cb=cb)
121
+ return
122
+
123
+ path = self.path
124
+ try:
125
+ # Ensure parent path exists
126
+ parent = path.parent
127
+ parent.mkdir(exist_ok=True, parents=True)
128
+
129
+ # Create backup if original file exists
130
+ backup_path: Path | None = None
131
+ if path.exists():
132
+ name = f"{path.name}.bak"
133
+ if not name.startswith("."):
134
+ name = f".{name}"
135
+ backup_path = parent / name
136
+ try:
137
+ import shutil
138
+
139
+ shutil.copy2(path, backup_path)
140
+ except Exception as e:
141
+ log.error("Failed to create backup: %s", e)
142
+ raise
143
+
144
+ # Write new content directly to original file
145
+ try:
146
+ self.write_file(path)
147
+ except Exception as e:
148
+ log.error("Failed to write file: %s", e)
149
+ # Restore from backup if it exists
150
+ if backup_path is not None:
151
+ log.info("Restoring backup")
152
+ backup_path.replace(path)
153
+ raise
154
+
155
+ self.dirty = False
156
+ self.saving = False
157
+ self.app.invalidate()
158
+ log.debug("File saved successfully")
159
+
160
+ # Run the callback
161
+ if callable(cb):
162
+ cb()
163
+
164
+ except Exception:
165
+ log.exception("An error occurred while saving the file")
166
+ if dialog := self.app.dialogs.get("save-as"):
167
+ dialog.show(tab=self, cb=cb)
168
+
169
+ def write_file(self, path: Path) -> None:
170
+ """Write the tab's data to a path.
171
+
172
+ Not implement in the base tab.
173
+
174
+ Args:
175
+ path: An path at which to save the file
176
+
177
+ """
178
+ raise NotImplementedError(
179
+ f"File saving not implement for `{self.__class__.__name__}` tab"
180
+ )
115
181
 
116
182
  def __pt_status__(self) -> StatusBarFields | None:
117
183
  """Return a list of statusbar field values shown then this tab is active."""
@@ -141,7 +207,7 @@ class Tab(metaclass=ABCMeta):
141
207
  Tab._refresh_tab()
142
208
 
143
209
  @staticmethod
144
- @add_cmd(filter=tab_has_focus, aliases=["w"])
210
+ @add_cmd(filter=tab_can_save, aliases=["w"])
145
211
  def _save_file(event: KeyPressEvent) -> None:
146
212
  """Save the current file."""
147
213
  if (tab := get_app().tab) is not None: