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
euporie/core/kernel.py CHANGED
@@ -9,16 +9,17 @@ import threading
9
9
  from collections import defaultdict
10
10
  from subprocess import DEVNULL # noqa S404 - Security implications considered
11
11
  from typing import TYPE_CHECKING, TypedDict
12
+ from uuid import uuid4
12
13
 
13
14
  import nbformat
14
15
  from _frozen_importlib import _DeadlockError
15
16
  from jupyter_client import AsyncKernelManager, KernelManager
16
17
  from jupyter_client.kernelspec import NATIVE_KERNEL_NAME, NoSuchKernel
17
- from jupyter_core.paths import jupyter_path
18
-
19
- from euporie.core.config import add_setting
18
+ from jupyter_core.paths import jupyter_path, jupyter_runtime_dir
19
+ from upath import UPath
20
20
 
21
21
  if TYPE_CHECKING:
22
+ from pathlib import Path
22
23
  from typing import Any, Callable, Coroutine
23
24
 
24
25
  from jupyter_client import KernelClient
@@ -50,20 +51,13 @@ class Kernel:
50
51
  Has the ability to run itself in it's own thread.
51
52
  """
52
53
 
53
- def _setup_loop(self) -> None:
54
- """Set the current loop the the kernel's event loop.
55
-
56
- This method is intended to be run in the kernel thread.
57
- """
58
- asyncio.set_event_loop(self.loop)
59
- self.loop.run_forever()
60
-
61
54
  def __init__(
62
55
  self,
63
56
  kernel_tab: KernelTab,
64
57
  threaded: bool = True,
65
58
  allow_stdin: bool = False,
66
59
  default_callbacks: MsgCallbacks | None = None,
60
+ connection_file: Path | None = None,
67
61
  ) -> None:
68
62
  """Call when the :py:class:`Kernel` is initialized.
69
63
 
@@ -72,6 +66,8 @@ class Kernel:
72
66
  threaded: If :py:const:`True`, run kernel communication in a separate thread
73
67
  allow_stdin: Whether the kernel is allowed to request input
74
68
  default_callbacks: The default callbacks to use on recipt of a message
69
+ connection_file: Path to a file from which to load or to hwich to save
70
+ kernel connection information
75
71
 
76
72
  """
77
73
  self.threaded = threaded
@@ -86,6 +82,7 @@ class Kernel:
86
82
  self.allow_stdin = allow_stdin
87
83
 
88
84
  self.kernel_tab = kernel_tab
85
+ self.connection_file = connection_file
89
86
  self.kc: KernelClient | None = None
90
87
  self.km = AsyncKernelManager(
91
88
  kernel_name=str(kernel_tab.kernel_name),
@@ -125,7 +122,14 @@ class Kernel:
125
122
  # Also this speeds up launch since importing IPython is pretty slow.
126
123
  self.km.kernel_spec_manager.kernel_dirs = jupyter_path("kernels")
127
124
 
125
+ def _setup_loop(self) -> None:
126
+ """Set the current loop the the kernel's event loop.
127
+
128
+ This method is intended to be run in the kernel thread.
129
+ """
130
+ asyncio.set_event_loop(self.loop)
128
131
  self.status_change_event = asyncio.Event()
132
+ self.loop.run_forever()
129
133
 
130
134
  def _aodo(
131
135
  self,
@@ -234,7 +238,7 @@ class Kernel:
234
238
  def missing(self) -> bool:
235
239
  """Return True if the requested kernel is not found."""
236
240
  try:
237
- self.km.kernel_spec
241
+ self.km.kernel_spec # noqa B018
238
242
  except NoSuchKernel:
239
243
  return True
240
244
  else:
@@ -272,16 +276,27 @@ class Kernel:
272
276
 
273
277
  # If we are connecting to an existing kernel, create a kernel client using
274
278
  # the given connection file
275
- if self.kernel_tab.app.config.kernel_connection_file:
276
- connection_file = self.kernel_tab.app.config.kernel_connection_file
277
- self.km.load_connection_file(connection_file)
278
- kc = self.km.client_factory(connection_file=connection_file)
279
+ runtime_dir = UPath(jupyter_runtime_dir())
280
+ if (connection_file := self.connection_file) is None:
281
+ id_ = str(uuid4())[:8]
282
+ connection_file = runtime_dir / f"kernel-euporie-{id_}.json"
283
+ connection_file_str = str(connection_file)
284
+ self.km.connection_file = connection_file_str
285
+
286
+ if connection_file.exists():
287
+ log.debug(
288
+ "Connecting to existing kernel using connection file '%s'",
289
+ connection_file,
290
+ )
291
+ self.km.load_connection_file(connection_file_str)
292
+ kc = self.km.client_factory(connection_file=connection_file_str)
279
293
  kc.load_connection_file()
280
294
  kc.start_channels()
281
295
  self.kc = kc
282
296
 
283
297
  # Otherwise, start a new kernel using the kernel manager
284
298
  else:
299
+ runtime_dir.mkdir(exist_ok=True, parents=True)
285
300
  while True:
286
301
  try:
287
302
  # TODO - send stdout to log
@@ -997,22 +1012,29 @@ class Kernel:
997
1012
  callback=cb,
998
1013
  )
999
1014
 
1000
- def change(self, name: str, cb: Callable | None = None) -> None:
1015
+ def change(
1016
+ self,
1017
+ name: str | None,
1018
+ connection_file: Path | None = None,
1019
+ cb: Callable | None = None,
1020
+ ) -> None:
1001
1021
  """Change the kernel.
1002
1022
 
1003
1023
  Args:
1004
1024
  name: The name of the kernel to change to
1025
+ connection_file: The path to the connection file to use
1005
1026
  cb: Callback to run once restarted
1006
1027
 
1007
1028
  """
1029
+ self.connection_file = connection_file
1008
1030
  self.status = "starting"
1009
1031
 
1010
1032
  # Update the tab's kernel spec
1011
- spec = self.specs.get(name, {}).get("spec", {})
1033
+ spec = self.specs.get(name or "", {}).get("spec", {})
1012
1034
  self.kernel_tab.metadata["kernelspec"] = {
1013
1035
  "name": name,
1014
- "display_name": spec["display_name"],
1015
- "language": spec["language"],
1036
+ "display_name": spec.get("display_name", ""),
1037
+ "language": spec.get("language", ""),
1016
1038
  }
1017
1039
 
1018
1040
  # Stop the old kernel
@@ -1021,7 +1043,8 @@ class Kernel:
1021
1043
 
1022
1044
  # Create a new kernel manager instance
1023
1045
  del self.km
1024
- self.km = AsyncKernelManager(kernel_name=name)
1046
+ kwargs = {} if name is None else {"kernel_name": name}
1047
+ self.km = AsyncKernelManager(**kwargs)
1025
1048
  self.error = None
1026
1049
 
1027
1050
  # Start the kernel
@@ -1053,12 +1076,14 @@ class Kernel:
1053
1076
 
1054
1077
  async def shutdown_(self) -> None:
1055
1078
  """Shut down the kernel and close the event loop if running in a thread."""
1079
+ # Clean up connection file
1080
+ self.km.cleanup_connection_file()
1081
+ # Stop kernel
1056
1082
  if self.km.has_kernel:
1057
1083
  await self.km.shutdown_kernel(now=True)
1084
+ # Stop event loop
1058
1085
  if self.threaded:
1059
1086
  self.loop.stop()
1060
- self.loop.close()
1061
- log.debug("Loop closed")
1062
1087
 
1063
1088
  def shutdown(self, wait: bool = False) -> None:
1064
1089
  """Shutdown the kernel and close the kernel's thread.
@@ -1076,17 +1101,3 @@ class Kernel:
1076
1101
  )
1077
1102
  if self.threaded:
1078
1103
  self.thread.join(timeout=5)
1079
-
1080
- # ################################### Settings ####################################
1081
-
1082
- add_setting(
1083
- name="kernel_connection_file",
1084
- flags=["--kernel-connection-file"],
1085
- type_=str,
1086
- help_="Attempt to connect to an existing kernel using a JSON connection info file",
1087
- default="",
1088
- description="""
1089
- Load connection info from JSON dict. This allows euporie to connect to
1090
- existing kernels.
1091
- """,
1092
- )
@@ -93,7 +93,11 @@ register_bindings(
93
93
  "newline": "enter",
94
94
  "accept-line": "enter",
95
95
  "backspace": ["backspace", "c-h"],
96
- "backward-kill-word": [("escape", "backspace"), ("escape", "c-h")],
96
+ "backward-kill-word": [
97
+ "c-backspace",
98
+ ("escape", "backspace"),
99
+ ("escape", "c-h"),
100
+ ],
97
101
  "start-selection": [
98
102
  "s-up",
99
103
  "s-down",
@@ -154,11 +154,11 @@ def load_mouse_bindings() -> "KeyBindings":
154
154
  if (mouse_limits := event.app.mouse_limits) is not None:
155
155
  x = max(
156
156
  mouse_limits.xpos,
157
- min(x, mouse_limits.xpos + mouse_limits.width - 1),
157
+ min(x, mouse_limits.xpos + (mouse_limits.width - 1)),
158
158
  )
159
159
  y = max(
160
160
  mouse_limits.ypos,
161
- min(y, mouse_limits.ypos + mouse_limits.height - 1),
161
+ min(y, mouse_limits.ypos + (mouse_limits.height - 1)),
162
162
  )
163
163
 
164
164
  # Call the mouse handler from the renderer.
euporie/core/keys.py CHANGED
@@ -8,6 +8,7 @@ from prompt_toolkit.keys import Keys
8
8
  extend_enum(Keys, "ControlEnter", "c-enter")
9
9
  extend_enum(Keys, "ControlShiftEnter", "c-s-enter")
10
10
  extend_enum(Keys, "ShiftEnter", "s-enter")
11
+ extend_enum(Keys, "ControlBackspace", "c-backspace")
11
12
 
12
13
  # Assign escape sequences to new keys
13
14
  ANSI_SEQUENCES["\x1b[27;5;13~"] = Keys.ControlEnter # type: ignore
@@ -20,6 +21,7 @@ ANSI_SEQUENCES["\x1b[27;6;13~"] = Keys.ControlShiftEnter # type: ignore
20
21
  ANSI_SEQUENCES["\x1b[13;6u"] = Keys.ControlShiftEnter # type: ignore
21
22
 
22
23
  # CSI-u control+key
24
+ ANSI_SEQUENCES["\x1b[32;5u"] = Keys.ControlSpace # type: ignore
23
25
  ANSI_SEQUENCES["\x1b[97;5u"] = Keys.ControlA # type: ignore
24
26
  ANSI_SEQUENCES["\x1b[98;5u"] = Keys.ControlB # type: ignore
25
27
  ANSI_SEQUENCES["\x1b[99;5u"] = Keys.ControlC # type: ignore
@@ -46,5 +48,6 @@ ANSI_SEQUENCES["\x1b[119;5u"] = Keys.ControlW # type: ignore
46
48
  ANSI_SEQUENCES["\x1b[120;5u"] = Keys.ControlX # type: ignore
47
49
  ANSI_SEQUENCES["\x1b[121;5u"] = Keys.ControlY # type: ignore
48
50
  ANSI_SEQUENCES["\x1b[122;5u"] = Keys.ControlZ # type: ignore
51
+ ANSI_SEQUENCES["\x1b[127;5u"] = Keys.ControlBackspace # type: ignore
49
52
  ANSI_SEQUENCES["\x1b[27;2;9~"] = Keys.BackTab # type: ignore
50
53
  ANSI_SEQUENCES["\x1b[9;2u"] = Keys.BackTab # type: ignore
euporie/core/launch.py CHANGED
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from importlib.metadata import entry_points
6
6
 
7
7
  from euporie.core.config import Config, add_setting
8
- from euporie.core.log import default_logs
8
+ from euporie.core.log import setup_logs
9
9
 
10
10
  APP_ALIASES = {
11
11
  "edit": "notebook",
@@ -23,12 +23,15 @@ class CoreApp:
23
23
  def launch(cls) -> None:
24
24
  """Launch the app."""
25
25
  # Set up default logging
26
- default_logs()
26
+ setup_logs()
27
27
 
28
28
  # Load the launcher's configuration
29
29
  cls.config.load(cls)
30
30
  app = cls.config.app
31
31
 
32
+ # Remove the app setting from the list of known settings
33
+ del Config.settings["app"]
34
+
32
35
  # Add aliases
33
36
  app = APP_ALIASES.get(app, app)
34
37
 
euporie/core/lexers.py CHANGED
@@ -1,8 +1,11 @@
1
1
  """Relating to lexers."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  from typing import TYPE_CHECKING
4
6
 
5
7
  from pygments.lexers import (
8
+ get_lexer_by_name,
6
9
  get_lexer_for_filename,
7
10
  guess_lexer,
8
11
  guess_lexer_for_filename,
@@ -10,11 +13,14 @@ from pygments.lexers import (
10
13
  from pygments.util import ClassNotFound
11
14
 
12
15
  if TYPE_CHECKING:
16
+ from pathlib import Path
17
+
13
18
  from pygments.lexer import Lexer as PygmentsLexerCls
14
- from upath import UPath
15
19
 
16
20
 
17
- def detect_lexer(text: "str" = "", path: "UPath|None" = None) -> "PygmentsLexerCls":
21
+ def detect_lexer(
22
+ text: str = "", path: Path | None = None, language: str = ""
23
+ ) -> PygmentsLexerCls | None:
18
24
  """Detect the pygments lexer for a file."""
19
25
  lexer = None
20
26
  if path is not None:
@@ -25,6 +31,11 @@ def detect_lexer(text: "str" = "", path: "UPath|None" = None) -> "PygmentsLexerC
25
31
  lexer = guess_lexer_for_filename(path, text)
26
32
  except ClassNotFound:
27
33
  pass
34
+ if lexer is None and language:
35
+ try:
36
+ lexer = get_lexer_by_name(language)
37
+ except ClassNotFound:
38
+ pass
28
39
  if lexer is None:
29
40
  try:
30
41
  lexer = guess_lexer(text)
euporie/core/log.py CHANGED
@@ -22,8 +22,10 @@ from prompt_toolkit.styles.pygments import style_from_pygments_cls
22
22
  from prompt_toolkit.styles.style import Style, merge_styles
23
23
  from pygments.styles import get_style_by_name
24
24
 
25
+ from euporie.core.config import add_setting
25
26
  from euporie.core.formatted_text.utils import indent, lex, wrap
26
27
  from euporie.core.style import LOG_STYLE
28
+ from euporie.core.utils import dict_merge
27
29
 
28
30
  if TYPE_CHECKING:
29
31
  from types import TracebackType
@@ -39,22 +41,8 @@ log = logging.getLogger(__name__)
39
41
  LOG_QUEUE: deque = deque(maxlen=1000)
40
42
 
41
43
 
42
- def dict_merge(target_dict: dict, input_dict: dict) -> None:
43
- """Merge the second dictionary onto the first."""
44
- for k in input_dict:
45
- if k in target_dict:
46
- if isinstance(target_dict[k], dict) and isinstance(input_dict[k], dict):
47
- dict_merge(target_dict[k], input_dict[k])
48
- elif isinstance(target_dict[k], list) and isinstance(input_dict[k], list):
49
- target_dict[k] = [*target_dict[k], *input_dict[k]]
50
- else:
51
- target_dict[k] = input_dict[k]
52
- else:
53
- target_dict[k] = input_dict[k]
54
-
55
-
56
44
  class FtFormatter(logging.Formatter):
57
- """Bae class for formatted text logging formatter."""
45
+ """Base class for formatted text logging formatter."""
58
46
 
59
47
  def __init__(self, *args: Any, **kwargs: Any) -> None:
60
48
  """Create a new formatter instance."""
@@ -201,16 +189,14 @@ class LogTabFormatter(FtFormatter):
201
189
  """Format a log record as formatted text."""
202
190
  record = self.prepare(record)
203
191
  output: StyleAndTextTuples = [
192
+ ("", "["),
204
193
  ("class:pygments.literal.date", f"{record.asctime}"),
205
- ("", " "),
194
+ ("", "] ["),
206
195
  (f"class:log.level.{record.levelname}", f"{record.levelname}"),
207
- ("", " " * (10 - len(record.levelname))),
196
+ ("", "] ["),
197
+ ("class:pygments.comment", f"{record.name}"),
198
+ ("", "] "),
208
199
  ("class:log,msg", record.message),
209
- ("", " "),
210
- (
211
- "class:pygments.comment",
212
- f"{record.name}.{record.funcName}:{record.lineno}",
213
- ),
214
200
  ("", "\n"),
215
201
  ]
216
202
  if record.exc_text:
@@ -283,82 +269,6 @@ class StdoutFormatter(FtFormatter):
283
269
  return FormattedText(output)
284
270
 
285
271
 
286
- def setup_logs(config: Config) -> None:
287
- """Configure the logger for euporie."""
288
- log_file_is_stdout = config.log_file in ("-", "/dev/stdout")
289
-
290
- log_config = {
291
- "version": 1,
292
- "disable_existing_loggers": False,
293
- "formatters": {
294
- "file_format": {
295
- "format": "{asctime} {levelname:<7} [{name}.{funcName}:{lineno}] {message}",
296
- "style": "{",
297
- "datefmt": "%Y-%m-%d %H:%M:%S",
298
- },
299
- "stdout_format": {
300
- "()": "euporie.core.log.StdoutFormatter",
301
- },
302
- "log_tab_format": {
303
- "()": "euporie.core.log.LogTabFormatter",
304
- },
305
- },
306
- "handlers": {
307
- **(
308
- {
309
- "file": {
310
- "level": config.log_level.upper() or "ERROR",
311
- "class": "logging.FileHandler",
312
- "filename": Path(config.log_file).expanduser(),
313
- "formatter": "file_format",
314
- }
315
- }
316
- if config.log_file and not log_file_is_stdout
317
- else {}
318
- ),
319
- "stdout": {
320
- "level": config.log_level.upper()
321
- if config.log_level and log_file_is_stdout
322
- else (
323
- "critical"
324
- if (app_cls := config.app_cls) is None
325
- else app_cls.log_stdout_level
326
- ),
327
- "class": "euporie.core.log.FormattedTextHandler",
328
- "pygments_theme": config.syntax_theme,
329
- "formatter": "stdout_format",
330
- "stream": sys.stdout,
331
- },
332
- "log_tab": {
333
- "level": config.log_level.upper() or "INFO",
334
- "class": "euporie.core.log.QueueHandler",
335
- "formatter": "log_tab_format",
336
- "queue": LOG_QUEUE,
337
- },
338
- },
339
- "loggers": {
340
- "euporie": {
341
- "level": config.log_level.upper() or "INFO",
342
- "handlers": ["log_tab", "stdout"]
343
- + (["file"] if not log_file_is_stdout and config.log_file else []),
344
- "propagate": False,
345
- },
346
- },
347
- # Log everything to the internal logger
348
- "root": {"handlers": ["log_tab"]},
349
- }
350
- # Update log_config based additional config provided
351
- if config.log_config:
352
- import json
353
-
354
- extra_config = json.loads(config.log_config)
355
- dict_merge(log_config, extra_config)
356
- # Configure the logger
357
- # Pytype used TypedDicts to validate the dictionary structure, but I cannot get
358
- # this to work for some reason...
359
- logging.config.dictConfig(log_config) # type: ignore
360
-
361
-
362
272
  class stdout_to_log:
363
273
  """A decorator which captures standard output and logs it."""
364
274
 
@@ -425,53 +335,139 @@ def handle_exception(
425
335
  log.critical("Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback))
426
336
 
427
337
 
428
- def default_logs() -> None:
429
- """Apply the default logging configuration before euporie's config is loaded."""
430
- logging.config.dictConfig(
431
- {
432
- "version": 1,
433
- "disable_existing_loggers": False,
434
- "formatters": {
435
- "file_format": {
436
- "format": "{asctime} {levelname:<7} [{name}.{funcName}:{lineno}] {message}",
437
- "style": "{",
438
- "datefmt": "%Y-%m-%d %H:%M:%S",
439
- },
440
- "stdout_format": {
441
- "()": StdoutFormatter,
442
- },
443
- "log_tab_format": {
444
- "()": LogTabFormatter,
445
- },
338
+ def setup_logs(config: Config | None = None) -> None:
339
+ """Configure the logger for euporie."""
340
+ # Default log config
341
+ log_config: dict[str, Any] = {
342
+ "version": 1,
343
+ "disable_existing_loggers": False,
344
+ "formatters": {
345
+ "file_format": {
346
+ "format": "{asctime} {levelname:<7} [{name}.{funcName}:{lineno}] {message}",
347
+ "style": "{",
348
+ "datefmt": "%Y-%m-%d %H:%M:%S",
349
+ },
350
+ "stdout_format": {
351
+ "()": StdoutFormatter,
446
352
  },
447
- "handlers": {
448
- "stdout": {
449
- "level": "INFO",
450
- "()": FormattedTextHandler,
451
- "formatter": "stdout_format",
452
- "stream": sys.stdout,
453
- },
454
- "log_tab": {
455
- "level": "INFO",
456
- "()": QueueHandler,
457
- "formatter": "log_tab_format",
458
- "queue": LOG_QUEUE,
459
- },
353
+ "log_tab_format": {
354
+ "()": LogTabFormatter,
460
355
  },
461
- "loggers": {
462
- "euporie": {
463
- "level": "INFO",
464
- "handlers": ["log_tab", "stdout"],
465
- "propagate": False,
466
- },
356
+ },
357
+ "handlers": {
358
+ "stdout": {
359
+ "level": "INFO",
360
+ "()": FormattedTextHandler,
361
+ "formatter": "stdout_format",
362
+ "stream": sys.stdout,
363
+ },
364
+ "log_tab": {
365
+ "level": "INFO",
366
+ "()": QueueHandler,
367
+ "formatter": "log_tab_format",
368
+ "queue": LOG_QUEUE,
369
+ },
370
+ },
371
+ "loggers": {
372
+ "euporie": {
373
+ "level": "INFO",
374
+ "handlers": ["log_tab", "stdout"],
375
+ "propagate": False,
467
376
  },
468
- # Log everything to the internal logger
469
- "root": {"handlers": ["log_tab"]},
470
- }
471
- ) # type: ignore
377
+ },
378
+ # Log everything to the internal logger
379
+ "root": {"handlers": ["log_tab"]},
380
+ }
381
+
382
+ if config is not None:
383
+ log_file = config.get("log_file", "")
384
+ log_file_is_stdout = log_file in {"-", "/dev/stdout"}
385
+ log_level = config.log_level.upper()
386
+
387
+ # Configure file handler
388
+ if log_file and not log_file_is_stdout:
389
+ log_config["handlers"]["file"] = {
390
+ "level": log_level,
391
+ "class": "logging.FileHandler",
392
+ "filename": Path(config.log_file).expanduser(),
393
+ "formatter": "file_format",
394
+ }
395
+ log_config["loggers"]["euporie"]["handlers"].append("file")
396
+
397
+ # Configure stdout handler
398
+ if log_file_is_stdout:
399
+ stdout_level = log_level
400
+ elif (app_cls := config.app_cls) is not None and (
401
+ log_stdout_level := app_cls.log_stdout_level
402
+ ):
403
+ stdout_level = log_stdout_level.upper()
404
+ else:
405
+ stdout_level = "CRITICAL"
406
+ log_config["handlers"]["stdout"]["level"] = stdout_level
407
+ log_config["handlers"]["stdout"]["pygments_theme"] = config.get(
408
+ "syntax_theme", "euporie"
409
+ )
410
+
411
+ # Configure euporie logger
412
+ log_config["loggers"]["euporie"]["level"] = config.log_level.upper()
413
+
414
+ # Update log_config based on additional config dict provided
415
+ if config.log_config:
416
+ import json
417
+
418
+ extra_config = json.loads(config.log_config)
419
+ dict_merge(log_config, extra_config)
420
+
421
+ # Configure the logger
422
+ # Pytype used TypedDicts to validate the dictionary structure, but I cannot get
423
+ # this to work for some reason...
424
+ logging.config.dictConfig(log_config) # type: ignore
472
425
 
473
426
  # Capture warnings so they show up in the logs
474
427
  logging.captureWarnings(True)
475
428
 
476
429
  # Log uncaught exceptions
477
430
  sys.excepthook = handle_exception
431
+
432
+
433
+ # ################################### Settings ########################################
434
+
435
+
436
+ add_setting(
437
+ name="log_file",
438
+ flags=["--log-file"],
439
+ nargs="?",
440
+ default="",
441
+ type_=str,
442
+ title="the log file path",
443
+ help_="File path for logs",
444
+ description="""
445
+ When set to a file path, the log output will be written to the given path.
446
+ If no value is given output will be sent to the standard output.
447
+ """,
448
+ )
449
+
450
+ add_setting(
451
+ name="log_level",
452
+ flags=["--log-level"],
453
+ type_=str,
454
+ default="warning",
455
+ title="the log level",
456
+ help_="Set the log level",
457
+ choices=["debug", "info", "warning", "error", "critical"],
458
+ description="""
459
+ When set, logging events at the given level are emitted.
460
+ """,
461
+ )
462
+
463
+ add_setting(
464
+ name="log_config",
465
+ flags=["--log-config"],
466
+ type_=str,
467
+ default=None,
468
+ title="additional logging configuration",
469
+ help_="Additional logging configuration",
470
+ description="""
471
+ A JSON string specifying additional logging configuration.
472
+ """,
473
+ )