euporie 2.8.1__py3-none-any.whl → 2.8.5__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 (129) hide show
  1. euporie/console/_commands.py +143 -0
  2. euporie/console/_settings.py +58 -0
  3. euporie/console/app.py +25 -71
  4. euporie/console/tabs/console.py +267 -147
  5. euporie/core/__init__.py +1 -9
  6. euporie/core/__main__.py +31 -5
  7. euporie/core/_settings.py +104 -0
  8. euporie/core/app/__init__.py +3 -0
  9. euporie/core/app/_commands.py +70 -0
  10. euporie/core/app/_settings.py +427 -0
  11. euporie/core/{app.py → app/app.py} +214 -572
  12. euporie/core/app/base.py +51 -0
  13. euporie/core/{current.py → app/current.py} +13 -4
  14. euporie/core/app/cursor.py +35 -0
  15. euporie/core/app/dummy.py +12 -0
  16. euporie/core/app/launch.py +28 -0
  17. euporie/core/bars/__init__.py +11 -0
  18. euporie/core/bars/command.py +182 -0
  19. euporie/core/bars/menu.py +258 -0
  20. euporie/core/{widgets → bars}/search.py +154 -57
  21. euporie/core/{widgets → bars}/status.py +9 -26
  22. euporie/core/clipboard.py +19 -80
  23. euporie/core/comm/base.py +8 -6
  24. euporie/core/comm/ipywidgets.py +21 -12
  25. euporie/core/comm/registry.py +2 -1
  26. euporie/core/commands.py +11 -5
  27. euporie/core/completion.py +3 -2
  28. euporie/core/config.py +368 -341
  29. euporie/core/convert/__init__.py +0 -30
  30. euporie/core/convert/datum.py +131 -60
  31. euporie/core/convert/formats/__init__.py +31 -0
  32. euporie/core/convert/formats/ansi.py +46 -30
  33. euporie/core/convert/formats/common.py +11 -23
  34. euporie/core/convert/formats/html.py +45 -40
  35. euporie/core/convert/formats/pil.py +1 -1
  36. euporie/core/convert/formats/png.py +3 -5
  37. euporie/core/convert/formats/sixel.py +3 -3
  38. euporie/core/convert/registry.py +11 -8
  39. euporie/core/convert/utils.py +50 -23
  40. euporie/core/diagnostics.py +2 -2
  41. euporie/core/filters.py +72 -82
  42. euporie/core/format.py +13 -2
  43. euporie/core/ft/ansi.py +1 -1
  44. euporie/core/ft/html.py +36 -36
  45. euporie/core/ft/table.py +1 -3
  46. euporie/core/ft/utils.py +4 -1
  47. euporie/core/graphics.py +216 -124
  48. euporie/core/history.py +2 -2
  49. euporie/core/inspection.py +3 -2
  50. euporie/core/io.py +207 -28
  51. euporie/core/kernel/__init__.py +1 -0
  52. euporie/core/{kernel.py → kernel/client.py} +100 -139
  53. euporie/core/kernel/manager.py +114 -0
  54. euporie/core/key_binding/bindings/__init__.py +2 -8
  55. euporie/core/key_binding/bindings/basic.py +47 -7
  56. euporie/core/key_binding/bindings/completion.py +3 -8
  57. euporie/core/key_binding/bindings/micro.py +5 -7
  58. euporie/core/key_binding/bindings/mouse.py +26 -24
  59. euporie/core/key_binding/bindings/terminal.py +193 -0
  60. euporie/core/key_binding/bindings/vi.py +46 -0
  61. euporie/core/key_binding/key_processor.py +43 -2
  62. euporie/core/key_binding/registry.py +2 -0
  63. euporie/core/key_binding/utils.py +22 -2
  64. euporie/core/keys.py +7156 -93
  65. euporie/core/layout/cache.py +35 -25
  66. euporie/core/layout/containers.py +280 -74
  67. euporie/core/layout/decor.py +5 -5
  68. euporie/core/layout/mouse.py +1 -1
  69. euporie/core/layout/print.py +16 -3
  70. euporie/core/layout/scroll.py +26 -28
  71. euporie/core/log.py +75 -60
  72. euporie/core/lsp.py +118 -24
  73. euporie/core/margins.py +60 -31
  74. euporie/core/path.py +2 -1
  75. euporie/core/renderer.py +58 -17
  76. euporie/core/style.py +60 -40
  77. euporie/core/suggest.py +103 -85
  78. euporie/core/tabs/__init__.py +34 -0
  79. euporie/core/tabs/_settings.py +113 -0
  80. euporie/core/tabs/base.py +11 -435
  81. euporie/core/tabs/kernel.py +420 -0
  82. euporie/core/tabs/notebook.py +20 -54
  83. euporie/core/utils.py +98 -6
  84. euporie/core/validation.py +1 -1
  85. euporie/core/widgets/_settings.py +188 -0
  86. euporie/core/widgets/cell.py +90 -158
  87. euporie/core/widgets/cell_outputs.py +25 -36
  88. euporie/core/widgets/decor.py +11 -41
  89. euporie/core/widgets/dialog.py +55 -44
  90. euporie/core/widgets/display.py +27 -24
  91. euporie/core/widgets/file_browser.py +5 -26
  92. euporie/core/widgets/forms.py +16 -12
  93. euporie/core/widgets/inputs.py +37 -81
  94. euporie/core/widgets/layout.py +7 -6
  95. euporie/core/widgets/logo.py +49 -0
  96. euporie/core/widgets/menu.py +13 -11
  97. euporie/core/widgets/pager.py +8 -11
  98. euporie/core/widgets/palette.py +6 -6
  99. euporie/hub/app.py +52 -31
  100. euporie/notebook/_commands.py +24 -0
  101. euporie/notebook/_settings.py +107 -0
  102. euporie/notebook/app.py +109 -210
  103. euporie/notebook/filters.py +1 -1
  104. euporie/notebook/tabs/__init__.py +46 -7
  105. euporie/notebook/tabs/_commands.py +714 -0
  106. euporie/notebook/tabs/_settings.py +32 -0
  107. euporie/notebook/tabs/display.py +2 -2
  108. euporie/notebook/tabs/edit.py +12 -7
  109. euporie/notebook/tabs/json.py +3 -3
  110. euporie/notebook/tabs/log.py +1 -18
  111. euporie/notebook/tabs/notebook.py +21 -674
  112. euporie/notebook/widgets/_commands.py +11 -0
  113. euporie/notebook/widgets/_settings.py +19 -0
  114. euporie/notebook/widgets/side_bar.py +14 -34
  115. euporie/preview/_settings.py +104 -0
  116. euporie/preview/app.py +8 -30
  117. euporie/preview/tabs/notebook.py +15 -86
  118. euporie/web/tabs/web.py +4 -6
  119. euporie/web/widgets/webview.py +5 -12
  120. {euporie-2.8.1.dist-info → euporie-2.8.5.dist-info}/METADATA +11 -15
  121. euporie-2.8.5.dist-info/RECORD +172 -0
  122. {euporie-2.8.1.dist-info → euporie-2.8.5.dist-info}/WHEEL +1 -1
  123. {euporie-2.8.1.dist-info → euporie-2.8.5.dist-info}/entry_points.txt +2 -2
  124. {euporie-2.8.1.dist-info → euporie-2.8.5.dist-info}/licenses/LICENSE +1 -1
  125. euporie/core/launch.py +0 -59
  126. euporie/core/terminal.py +0 -527
  127. euporie-2.8.1.dist-info/RECORD +0 -146
  128. {euporie-2.8.1.data → euporie-2.8.5.data}/data/share/applications/euporie-console.desktop +0 -0
  129. {euporie-2.8.1.data → euporie-2.8.5.data}/data/share/applications/euporie-notebook.desktop +0 -0
euporie/core/config.py CHANGED
@@ -8,10 +8,17 @@ import logging
8
8
  import os
9
9
  import sys
10
10
  from ast import literal_eval
11
- from collections import ChainMap
12
- from functools import partial
11
+ from functools import cached_property, partial
13
12
  from pathlib import Path
14
- from typing import TYPE_CHECKING, Protocol, TextIO, cast
13
+ from types import SimpleNamespace
14
+ from typing import (
15
+ TYPE_CHECKING,
16
+ Any,
17
+ Callable,
18
+ Optional,
19
+ TextIO,
20
+ cast,
21
+ )
15
22
 
16
23
  import fastjsonschema
17
24
  from platformdirs import user_config_dir
@@ -20,23 +27,17 @@ from prompt_toolkit.filters.utils import to_filter
20
27
  from prompt_toolkit.utils import Event
21
28
  from upath import UPath
22
29
 
23
- from euporie.core import __app_name__, __copyright__, __version__
30
+ from euporie.core import __app_name__, __copyright__
24
31
  from euporie.core.commands import add_cmd, get_cmd
25
32
 
26
33
  if TYPE_CHECKING:
27
- from typing import IO, Any, Callable, ClassVar, Optional, Sequence
34
+ from collections.abc import Iterable, Mapping, Sequence
35
+ from typing import IO, Any, Callable, ClassVar, Optional
28
36
 
29
- from prompt_toolkit.filters.base import Filter, FilterOrBool
37
+ from prompt_toolkit.filters.base import FilterOrBool
30
38
 
31
39
  from euporie.core.widgets.menu import MenuItem
32
40
 
33
- class ConfigurableApp(Protocol):
34
- """An application with configuration."""
35
-
36
- config: Config
37
- name: str
38
- log_stdout_level: str
39
-
40
41
 
41
42
  log = logging.getLogger(__name__)
42
43
 
@@ -52,25 +53,34 @@ _SCHEMA_TYPES: dict[type | Callable, str] = {
52
53
  class ArgumentParser(argparse.ArgumentParser):
53
54
  """An argument parser which lexes and formats the help message before printing it."""
54
55
 
56
+ def __init__(self, *args: Any, config: Config, **kwargs: Any) -> None:
57
+ """Initialize while saving a reference to the current config."""
58
+ super().__init__(*args, **kwargs)
59
+ self.config = config
60
+
55
61
  def _print_message(self, message: str, file: IO[str] | None = None) -> None:
56
62
  from prompt_toolkit.formatted_text.base import FormattedText
57
63
  from prompt_toolkit.lexers.pygments import _token_cache
58
64
  from prompt_toolkit.shortcuts.utils import print_formatted_text
59
65
  from prompt_toolkit.styles.pygments import style_from_pygments_cls
60
66
 
61
- from euporie.core.pygments import ArgparseLexer, EuporiePygmentsStyle
67
+ from euporie.core.pygments import ArgparseLexer
68
+ from euporie.core.style import get_style_by_name
62
69
 
63
70
  if message:
64
71
  file = cast("Optional[TextIO]", file)
72
+ style = style_from_pygments_cls(get_style_by_name(self.config.syntax_theme))
65
73
  print_formatted_text(
66
74
  FormattedText(
67
75
  [
68
76
  (_token_cache[t], v)
69
- for _, t, v in ArgparseLexer().get_tokens_unprocessed(message)
77
+ for _, t, v in ArgparseLexer().get_tokens_unprocessed(
78
+ message.rstrip("\n")
79
+ )
70
80
  ]
71
81
  ),
72
82
  file=file,
73
- style=style_from_pygments_cls(EuporiePygmentsStyle),
83
+ style=style,
74
84
  include_default_pygments_style=False,
75
85
  )
76
86
 
@@ -144,12 +154,13 @@ class Setting:
144
154
  def __init__(
145
155
  self,
146
156
  name: str,
147
- default: Any,
148
- help_: str,
149
- description: str,
157
+ group: str | Sequence[str],
158
+ default: Any = None,
159
+ help_: str = "",
160
+ description: str = "",
150
161
  type_: Callable[[Any], Any] | None = None,
151
162
  title: str | None = None,
152
- choices: list[Any] | None = None,
163
+ choices: list[Any] | Callable[[], list[Any]] | None = None,
153
164
  action: argparse.Action | str | None = None,
154
165
  flags: list[str] | None = None,
155
166
  schema: dict[str, Any] | None = None,
@@ -161,127 +172,33 @@ class Setting:
161
172
  ) -> None:
162
173
  """Create a new configuration item."""
163
174
  self.name = name
175
+ self.groups = {group} if isinstance(group, str) else set(group)
164
176
  self.default = default
165
177
  self._value = default
166
178
  self.title = title or self.name.replace("_", " ")
167
179
  self.help = help_
168
180
  self.description = description
169
- self.choices = choices
181
+ self._choices = choices
170
182
  self.type = type_ or type(default)
171
183
  self.action = action or TYPE_ACTIONS.get(self.type)
172
- self.flags = flags or [f"--{name.replace('_','-')}"]
184
+ self.flags = flags or [f"--{name.replace('_', '-')}"]
173
185
  self._schema: dict[str, Any] = {
174
186
  "type": _SCHEMA_TYPES.get(self.type),
175
187
  **(schema or {}),
176
188
  }
177
189
  self.nargs = nargs
178
190
  self.hidden = to_filter(hidden)
179
- self.kwargs = kwargs
191
+ self.hooks = hooks
180
192
  self.cmd_filter = cmd_filter
193
+ self.kwargs = kwargs
181
194
 
182
- self.event = Event(self)
183
- for hook in hooks or []:
184
- self.event += hook
185
-
186
- self.register_commands()
187
-
188
- def register_commands(self) -> None:
189
- """Register commands to set this setting."""
190
- name = self.name.replace("_", "-")
191
- schema = self.schema
192
-
193
- if schema.get("type") == "array":
194
- for choice in self.choices or schema.get("items", {}).get("enum") or []:
195
- add_cmd(
196
- name=f"toggle-{name}-{choice}",
197
- hidden=self.hidden,
198
- toggled=Condition(partial(lambda x: x in self.value, choice)),
199
- title=f"Add {choice} to {self.title} setting",
200
- menu_title=str(choice).replace("_", " ").capitalize(),
201
- description=f'Add or remove "{choice}" to or from the list of "{self.name}"',
202
- filter=self.cmd_filter,
203
- )(
204
- partial(
205
- lambda choice: (
206
- self.value.remove
207
- if choice in self.value
208
- else self.value.append
209
- )(choice),
210
- choice,
211
- )
212
- )
213
-
214
- elif self.type == bool:
215
-
216
- def _toggled() -> bool:
217
- from euporie.core.current import get_app
218
-
219
- app = get_app()
220
- value = app.config.get(self.name)
221
- return bool(getattr(app.config, self.name, not value))
222
-
223
- toggled_filter = Condition(_toggled)
224
-
225
- add_cmd(
226
- name=f"toggle-{name}",
227
- toggled=toggled_filter,
228
- hidden=self.hidden,
229
- title=f"Toggle {self.title}",
230
- menu_title=self.kwargs.get("menu_title", self.title.capitalize()),
231
- description=self.help,
232
- filter=self.cmd_filter,
233
- )(self.toggle)
234
-
235
- elif self.type == int or self.choices is not None:
236
- add_cmd(
237
- name=f"switch-{name}",
238
- hidden=self.hidden,
239
- title=f"Switch {self.title}",
240
- menu_title=self.kwargs.get("menu_title"),
241
- description=f'Switch the value of the "{self.name}" configuration option.',
242
- filter=self.cmd_filter,
243
- )(self.toggle)
244
-
245
- for choice in self.choices or schema.get("enum", []) or []:
246
- add_cmd(
247
- name=f"set-{name}-{choice}",
248
- hidden=self.hidden,
249
- toggled=Condition(partial(lambda x: self.value == x, choice)),
250
- title=f"Set {self.title} to {choice}",
251
- menu_title=str(choice).replace("_", " ").capitalize(),
252
- description=f'Set the value of the "{self.name}" '
253
- f'configuration option to "{choice}"',
254
- filter=self.cmd_filter,
255
- )(partial(setattr, self, "value", choice))
256
-
257
- def toggle(self) -> None:
258
- """Toggle the setting's value."""
259
- if self.type == bool:
260
- new = not self.value
261
- elif (
262
- self.type == int
263
- and "minimum" in (schema := self.schema)
264
- and "maximum" in schema
265
- ):
266
- new = schema["minimum"] + (self.value - schema["minimum"] + 1) % (
267
- schema["maximum"] + 1
268
- )
269
- elif self.choices is not None:
270
- new = self.choices[(self.choices.index(self.value) + 1) % len(self.choices)]
195
+ @cached_property
196
+ def choices(self) -> list[Any] | None:
197
+ """Compute the setting's available options."""
198
+ if callable(self._choices):
199
+ return self._choices()
271
200
  else:
272
- raise NotImplementedError
273
- self.value = new
274
-
275
- @property
276
- def value(self) -> Any:
277
- """Return the current value."""
278
- return self._value
279
-
280
- @value.setter
281
- def value(self, new: Any) -> None:
282
- """Set the current value."""
283
- self._value = new
284
- self.event.fire()
201
+ return self._choices
285
202
 
286
203
  @property
287
204
  def schema(self) -> dict[str, Any]:
@@ -327,13 +244,17 @@ class Setting:
327
244
  @property
328
245
  def parser_args(self) -> tuple[list[str], dict[str, Any]]:
329
246
  """Return arguments for construction of an :class:`argparse.ArgumentParser`."""
330
- # Do not set defaults for command line arguments, as default values
331
- # would override values set in the configuration file
247
+ # If empty flags are passed, do not expose setting on command line
248
+ if self.flags is not None and not self.flags:
249
+ return [], {}
332
250
  args = self.flags or [self.name]
333
251
 
334
252
  kwargs: dict[str, Any] = {
335
253
  "action": self.action,
336
- "help": self.help,
254
+ "help": argparse.SUPPRESS if self.hidden() else self.help,
255
+ # Do not set defaults for command line arguments, as default values
256
+ # would override values set in the configuration file
257
+ # "default": self.default,
337
258
  }
338
259
 
339
260
  if self.nargs:
@@ -351,131 +272,246 @@ class Setting:
351
272
 
352
273
  def __repr__(self) -> str:
353
274
  """Represent a :py:class`Setting` instance as a string."""
354
- return f"<Setting {self.name}={self.value.__repr__()}>"
275
+ return f"<Setting {self.name}: {self.type}>"
276
+
277
+
278
+ class DefaultNamespace(SimpleNamespace):
279
+ """A namespace that creates default values for undefined attributes using a factory function.
280
+
281
+ This class extends SimpleNamespace to provide automatic creation of default values when
282
+ accessing undefined attributes, similar to collections.defaultdict but for object attributes.
283
+
284
+ Attributes:
285
+ _factory: A callable that generates default values for undefined attributes.
286
+ If None, AttributeError will be raised for undefined attributes.
287
+
288
+ Examples:
289
+ >>> # Create namespace with list factory
290
+ >>> ns = DefaultNamespace(default_factory=list)
291
+ >>> ns.numbers.append(1) # Creates new list automatically
292
+ >>> ns.numbers
293
+ [1]
294
+
295
+ >>> # Create with initial values
296
+ >>> ns = DefaultNamespace(default_factory=int, x=1, y=2)
297
+ >>> ns.x
298
+ 1
299
+ >>> ns.z # Creates new int (0) automatically
300
+ 0
301
+ """
302
+
303
+ def __init__(
304
+ self,
305
+ default_factory: Callable[[str], Any] | None = None,
306
+ mapping_or_iterable: Mapping | Iterable[tuple[str, Any]] | None = None,
307
+ /,
308
+ **kwargs: Any,
309
+ ) -> None:
310
+ """Initialize the DefaultNamespace.
311
+
312
+ Args:
313
+ default_factory: A callable that takes an attribute name and returns a default value
314
+ when accessing undefined attributes. If None, AttributeError is raised for
315
+ undefined attributes.
316
+ mapping_or_iterable: An optional mapping or iterable of (key, value) pairs to
317
+ initialize the namespace with.
318
+ **kwargs: Additional keyword arguments to initialize the namespace with.
319
+ """
320
+ if mapping_or_iterable is None:
321
+ mapping_or_iterable = {}
322
+ super().__init__(**dict(mapping_or_iterable), **kwargs)
323
+ self._factory = default_factory
324
+
325
+ def __getattribute__(self, name: str) -> Any:
326
+ """Get an attribute value, creating it if undefined using the default_factory.
327
+
328
+ This method intercepts attribute access to provide default value creation
329
+ for undefined attributes using the default_factory.
330
+
331
+ Args:
332
+ name: The name of the attribute to access.
333
+
334
+ Returns:
335
+ The attribute value, either existing or newly created.
336
+
337
+ Raises:
338
+ AttributeError: If the attribute doesn't exist and no default_factory is set.
339
+ """
340
+ try:
341
+ return super().__getattribute__(name)
342
+ except AttributeError:
343
+ factory = super().__getattribute__("_factory")
344
+ if factory is None:
345
+ raise
346
+ value = factory(name)
347
+ setattr(self, name, value)
348
+ return value
355
349
 
356
350
 
357
351
  class Config:
358
352
  """A configuration store."""
359
353
 
360
- settings: ClassVar[dict[str, Setting]] = {}
361
- conf_file_name = "config.json"
354
+ _conf_file_name = "config.json"
355
+ _settings: ClassVar[dict[str, Setting]] = {}
362
356
 
363
- def __init__(self) -> None:
357
+ def __init__(self, _help: str = "", **kwargs: Any) -> None:
364
358
  """Create a new configuration object instance."""
365
- self.app_name: str = "base"
366
- self.app_cls: type[ConfigurableApp] | None = None
367
-
368
- def _save(self, setting: Setting) -> None:
369
- """Save settings to user's configuration file."""
370
- json_data = self.load_config_file()
371
- json_data.setdefault(self.app_name, {})[setting.name] = setting.value
372
- if self.valid_user:
373
- log.debug("Saving setting `%s`", setting)
374
- with self.config_file_path.open("w") as f:
375
- json.dump(json_data, f, indent=2)
359
+ self._help = _help
376
360
 
377
- def load(self, cls: type[ConfigurableApp]) -> None:
378
- """Load the command line, environment, and user configuration."""
379
- from euporie.core.log import setup_logs
380
-
381
- self.app_cls = cls
382
- self.app_name = cls.name
383
- log.debug("Loading config for %s", self.app_name)
361
+ # Create stores
362
+ self.events = DefaultNamespace(
363
+ lambda name: Event(self._settings[name], self._save)
364
+ )
365
+ self.filters = DefaultNamespace(
366
+ lambda name: Condition(lambda: bool(self._values[name]))
367
+ )
368
+ self.defaults = DefaultNamespace(lambda x: self._settings[x].default)
369
+ self.choices = DefaultNamespace(lambda x: self._settings[x].choices)
370
+ self.menus = DefaultNamespace(lambda x: self._settings[x].menu)
384
371
 
385
372
  user_conf_dir = Path(user_config_dir(__app_name__, appauthor=None))
386
373
  user_conf_dir.mkdir(exist_ok=True, parents=True)
387
- self.config_file_path = (user_conf_dir / self.conf_file_name).with_suffix(
374
+ self._config_file_path = (user_conf_dir / self._conf_file_name).with_suffix(
388
375
  ".json"
389
376
  )
390
- self.valid_user = True
391
-
392
- config_maps = {
393
- # Load command line arguments
394
- "command line arguments": self.load_args(),
395
- # Load app specific env vars
396
- "app-specific environment variable": self.load_env(app_name=self.app_name),
397
- # Load global env vars
398
- "global environment variable": self.load_env(),
399
- # Load app specific user config
400
- "app-specific user configuration": self.load_user(app_name=self.app_name),
401
- # Load global user config
402
- "user configuration": self.load_user(),
377
+ self._valid_user = True
378
+ self._schema_validate = fastjsonschema.compile(self._schema, use_default=False)
379
+
380
+ # Register commands for each setting
381
+ for setting in self._settings.values():
382
+ self._register_commands(setting)
383
+
384
+ self._values = {
385
+ # Setting defaults
386
+ **{k: v.default for k, v in self._settings.items()},
387
+ # Key-word arguments
388
+ **kwargs,
403
389
  }
404
- set_values = ChainMap(*config_maps.values())
405
-
406
- for name, setting in Config.settings.items():
407
- if setting.name in set_values:
408
- # Set value without triggering hooks
409
- setting._value = set_values[name]
410
- setting.event += self._save
411
-
412
- # Set-up logs
413
- setup_logs(self)
414
-
415
- # Save a list of unknown configuration options so we can warn about them once
416
- # the logs are configured
417
- for map_name, map_values in config_maps.items():
418
- for option_name in map_values.keys() - Config.settings.keys():
419
- if not isinstance(set_values[option_name], dict):
390
+
391
+ def load(self) -> None:
392
+ """Load the configuration options from non-local sources."""
393
+ from euporie.core.log import BufferedLogs, setup_logs
394
+
395
+ # Buffer logs and replay them after settings are configured
396
+ with BufferedLogs(logger=log):
397
+ try:
398
+ # Validate configured values
399
+ self._values.update(self._validate(self._load_user(), "config file"))
400
+ self._values.update(
401
+ self._validate(self._load_user(prefix=self.app), "app config file")
402
+ )
403
+ self._values.update(
404
+ self._validate(self._load_env(), "environment variable")
405
+ )
406
+ self._values.update(
407
+ self._validate(
408
+ self._load_env(prefix=self.app), "app environment variable"
409
+ )
410
+ )
411
+ self._values.update(
412
+ self._validate(self._load_args(), "command line parameter")
413
+ )
414
+ finally:
415
+ # Set-up logs even if configuration validation fails
416
+ setup_logs(self)
417
+
418
+ def _validate(self, data: dict[str, Any], group: str) -> dict[str, Any]:
419
+ """Validate settings values."""
420
+ validated = {}
421
+ for name, value in data.items():
422
+ if name in self._settings:
423
+ # Convert to json and back to attain json types
424
+ json_data = json.loads(_json_encoder.encode({name: value}))
425
+ try:
426
+ self._schema_validate(json_data)
427
+ except fastjsonschema.JsonSchemaValueException as error:
428
+ # Warn about badly configured settings
420
429
  log.warning(
421
- "Configuration option '%s' not recognised in %s",
422
- option_name,
423
- map_name,
430
+ "Error in %s setting: `%s = %r`\n%s",
431
+ group,
432
+ name,
433
+ value,
434
+ error.message.replace("data.", ""),
424
435
  )
436
+ else:
437
+ # Store validated setting values
438
+ validated[name] = value
439
+ else:
440
+ # Warn about unknown configuration options
441
+ if not isinstance(value, dict):
442
+ log.warning(
443
+ "Configuration option '%s' not recognised in %s", name, group
444
+ )
445
+ return validated
446
+
447
+ def _save(self, setting: Setting) -> None:
448
+ """Save settings to user's configuration file."""
449
+ json_data = self._load_config_file()
450
+ json_data.setdefault(self.app, {})[setting.name] = getattr(self, setting.name)
451
+ if self._valid_user:
452
+ log.debug("Saving setting `%s`", setting)
453
+ with self._config_file_path.open("w") as f:
454
+ json.dump(json_data, f, indent=2)
425
455
 
426
456
  @property
427
- def schema(self) -> dict[str, Any]:
457
+ def _schema(self) -> dict[str, Any]:
428
458
  """Return a JSON schema for the config."""
429
459
  return {
430
460
  "title": "Euporie Configuration",
431
461
  "description": "A configuration for euporie",
432
462
  "type": "object",
433
- "properties": {name: item.schema for name, item in self.settings.items()},
463
+ "properties": {name: item.schema for name, item in self._settings.items()},
464
+ }
465
+
466
+ @property
467
+ def settings(self) -> dict[str, Setting]:
468
+ """Return the currently active settings."""
469
+ return {
470
+ name: setting
471
+ for name, setting in self._settings.items()
472
+ if any(group in sys.modules or group == "*" for group in setting.groups)
434
473
  }
435
474
 
436
- def load_parser(self) -> argparse.ArgumentParser:
475
+ def _load_parser(self) -> argparse.ArgumentParser:
437
476
  """Construct an :py:class:`ArgumentParser`."""
438
477
  parser = ArgumentParser(
439
- description=self.app_cls.__doc__,
478
+ description=self._help,
440
479
  epilog=__copyright__,
441
480
  allow_abbrev=True,
442
481
  formatter_class=argparse.MetavarTypeHelpFormatter,
482
+ config=self,
483
+ argument_default=argparse.SUPPRESS,
484
+ # exit_on_error=False,
443
485
  )
444
486
  # Add options to the relevant subparsers
445
487
  for setting in self.settings.values():
488
+ # if self._values[setting.name] != setting.default:
446
489
  args, kwargs = setting.parser_args
490
+ # Make already specified positional arguments optional
491
+ if (
492
+ not any(x.startswith("-") for x in setting.flags)
493
+ and getattr(self, setting.name) != setting.default
494
+ ):
495
+ kwargs["nargs"] = "?"
447
496
  parser.add_argument(*args, **kwargs)
448
497
  return parser
449
498
 
450
- def load_args(self) -> dict[str, Any]:
499
+ def _load_args(self) -> dict[str, Any]:
451
500
  """Attempt to load configuration settings from commandline flags."""
452
- result = {}
453
501
  # Parse known arguments
454
- namespace, remainder = self.load_parser().parse_known_intermixed_args()
455
- # Update argv to leave the remaining arguments for subsequent apps
456
- sys.argv[1:] = remainder
502
+ namespace, remainder = self._load_parser().parse_known_intermixed_args()
457
503
  # Validate arguments
458
- for name, value in vars(namespace).items():
459
- if value is not None:
460
- # Convert to json and back to attain json types
461
- json_data = json.loads(_json_encoder.encode({name: value}))
462
- try:
463
- fastjsonschema.validate(self.schema, json_data)
464
- except fastjsonschema.JsonSchemaValueException as error:
465
- log.warning("Error in command line parameter `%s`: %s", name, error)
466
- log.warning("%s: %s", name, value)
467
- else:
468
- result[name] = value
469
- return result
504
+ return vars(namespace)
470
505
 
471
- def load_env(self, app_name: str = "") -> dict[str, Any]:
506
+ def _load_env(self, prefix: str = "") -> dict[str, Any]:
472
507
  """Attempt to load configuration settings from environment variables."""
473
508
  result = {}
474
- for name, setting in self.settings.items():
475
- if app_name:
476
- env = f"{__app_name__.upper()}_{self.app_name.upper()}_{setting.name.upper()}"
509
+ for name, setting in self._settings.items():
510
+ if prefix:
511
+ env = f"{__app_name__}_{prefix}_{setting.name}"
477
512
  else:
478
- env = f"{__app_name__.upper()}_{setting.name.upper()}"
513
+ env = f"{__app_name__}_{setting.name}"
514
+ env = env.upper()
479
515
  if env in os.environ:
480
516
  value = os.environ.get(env)
481
517
  parsed_value: Any = value
@@ -495,163 +531,154 @@ class Config:
495
531
  try:
496
532
  parsed_value = setting.type(value)
497
533
  except (ValueError, TypeError):
498
- log.warning(
499
- "Environment variable `%s` not understood" " - `%s` expected",
500
- env,
501
- setting.type.__name__,
502
- )
503
- else:
504
- json_data = json.loads(_json_encoder.encode({name: parsed_value}))
505
- try:
506
- fastjsonschema.validate(self.schema, json_data)
507
- except fastjsonschema.JsonSchemaValueException as error:
508
- log.error("Error in environment variable: `%s`\n%s", env, error)
509
- else:
510
- result[name] = parsed_value
534
+ pass
535
+ result[name] = parsed_value
511
536
  return result
512
537
 
513
- def load_config_file(self) -> dict[str, Any]:
538
+ def _load_config_file(self) -> dict[str, Any]:
514
539
  """Attempt to load JSON configuration file."""
515
540
  results = {}
516
- assert isinstance(self.config_file_path, Path)
517
- if self.valid_user and self.config_file_path.exists():
518
- with self.config_file_path.open() as f:
541
+ assert isinstance(self._config_file_path, Path)
542
+ if self._valid_user and self._config_file_path.exists():
543
+ with self._config_file_path.open() as f:
519
544
  try:
520
545
  json_data = json.load(f)
521
546
  except json.decoder.JSONDecodeError:
522
547
  log.error(
523
548
  "Could not parse the configuration file: %s\nIs it valid json?",
524
- self.config_file_path,
549
+ self._config_file_path,
525
550
  )
526
- self.valid_user = False
551
+ self._valid_user = False
527
552
  else:
528
553
  results.update(json_data)
529
554
  return results
530
555
 
531
- def load_user(self, app_name: str = "") -> dict[str, Any]:
556
+ def _load_user(self, prefix: str | None = None) -> dict[str, Any]:
532
557
  """Attempt to load JSON configuration file."""
533
558
  results = {}
534
559
  # Load config file
535
- json_data = self.load_config_file()
560
+ json_data = self._load_config_file()
536
561
  # Load section for the current app
537
- if app_name:
538
- json_data = json_data.get(self.app_name, {})
539
- # Validate the configuration file
540
- try:
541
- # Validate a copy so the original data is not modified
542
- fastjsonschema.validate(self.schema, dict(json_data))
543
- except fastjsonschema.JsonSchemaValueException as error:
544
- log.warning("Error in config file: `%s`: %s", self.config_file_pathi, error)
545
- self.valid_user = False
546
- else:
547
- results.update(json_data)
562
+ if prefix is not None:
563
+ json_data = json_data.get(prefix, {})
564
+ for name, value in json_data.items():
565
+ if (setting := self._settings.get(name)) is not None:
566
+ # Attempt to cast the value to the desired type
567
+ try:
568
+ value = setting.type(value)
569
+ except (ValueError, TypeError):
570
+ pass
571
+ results[name] = value
548
572
  return results
549
573
 
550
- def get(self, name: str, default: Any = None) -> Any:
551
- """Access a configuration value, falling back to the default value if unset.
552
-
553
- Args:
554
- name: The name of the attribute to access.
555
- default: The value to return if the name is not found
556
-
557
- Returns:
558
- The configuration variable value.
559
-
560
- """
561
- if name in self.settings:
562
- return self.settings[name].value
563
- else:
564
- return default
565
-
566
- def get_item(self, name: str) -> Any:
567
- """Access a configuration item.
568
-
569
- Args:
570
- name: The name of the attribute to access.
571
-
572
- Returns:
573
- The configuration item.
574
-
575
- """
576
- return self.settings.get(name)
574
+ def _register_commands(self, setting: Setting) -> None:
575
+ """Register commands to set this setting."""
576
+ cmd_name = setting.name.replace("_", "-")
577
+ schema = setting.schema
577
578
 
578
- def filter(self, name: str) -> Filter:
579
- """Return a :py:class:`Filter` for a configuration item."""
580
- return Condition(partial(self.get, name))
579
+ if schema.get("type") == "array":
580
+ for choice in setting.choices or schema.get("items", {}).get("enum") or []:
581
+ add_cmd(
582
+ name=f"toggle-{cmd_name}-{choice}",
583
+ hidden=setting.hidden,
584
+ toggled=Condition(
585
+ partial(lambda x: x in self._values[setting.name], choice)
586
+ ),
587
+ title=f"Add {choice} to {setting.title} setting",
588
+ menu_title=str(choice).replace("_", " ").capitalize(),
589
+ description=f'Add or remove "{choice}" to or from the list of "{setting.name}"',
590
+ filter=setting.cmd_filter,
591
+ )(
592
+ partial(
593
+ lambda choice: (
594
+ self._values[setting.name].remove
595
+ if choice in getattr(self, setting.name)
596
+ else self._values[setting.name].append
597
+ )(choice),
598
+ choice,
599
+ )
600
+ )
581
601
 
582
- def __getattr__(self, name: str) -> Any:
583
- """Enable access of config elements via dotted attributes.
602
+ elif setting.type is bool:
603
+ add_cmd(
604
+ name=f"toggle-{cmd_name}",
605
+ toggled=Condition(lambda: bool(self._values[setting.name])),
606
+ hidden=setting.hidden,
607
+ title=f"Toggle {setting.title}",
608
+ menu_title=setting.kwargs.get("menu_title", setting.title.capitalize()),
609
+ description=setting.help,
610
+ filter=setting.cmd_filter,
611
+ )(partial(self.toggle, setting.name))
612
+
613
+ elif setting.type is int or setting.choices is not None:
614
+ add_cmd(
615
+ name=f"switch-{cmd_name}",
616
+ hidden=setting.hidden,
617
+ title=f"Switch {setting.title}",
618
+ menu_title=setting.kwargs.get("menu_title"),
619
+ description=f'Switch the value of the "{setting.name}" configuration option.',
620
+ filter=setting.cmd_filter,
621
+ )(partial(self.toggle, setting.name))
622
+
623
+ for choice in setting.choices or schema.get("enum", []) or []:
624
+ add_cmd(
625
+ name=f"set-{cmd_name}-{choice}",
626
+ hidden=setting.hidden,
627
+ toggled=Condition(
628
+ partial(lambda x: self._values[setting.name] == x, choice)
629
+ ),
630
+ title=f"Set {setting.title} to {choice}",
631
+ menu_title=str(choice).replace("_", " ").capitalize(),
632
+ description=f'Set the value of the "{setting.name}" '
633
+ f'configuration option to "{choice}"',
634
+ filter=setting.cmd_filter,
635
+ )(partial(setattr, self, setting.name, choice))
584
636
 
585
- Args:
586
- name: The name of the attribute to access.
637
+ def toggle(self, name: str) -> None:
638
+ """Toggle the setting's value."""
639
+ setting = self._settings[name]
640
+ value = self._values[name]
587
641
 
588
- Returns:
589
- The configuration variable value.
642
+ if setting.type is bool:
643
+ new = not value
644
+ elif (
645
+ setting.type is int
646
+ and "minimum" in (schema := setting.schema)
647
+ and "maximum" in schema
648
+ ):
649
+ new = schema["minimum"] + (value - schema["minimum"] + 1) % (
650
+ schema["maximum"] + 1
651
+ )
652
+ elif setting.choices is not None:
653
+ new = setting.choices[
654
+ (setting.choices.index(value) + 1) % len(setting.choices)
655
+ ]
656
+ else:
657
+ raise NotImplementedError
658
+ setattr(self, name, new)
590
659
 
591
- """
592
- return self.get(name)
660
+ def __getattribute__(self, name: str) -> Any:
661
+ """Enable access of config elements via dotted attributes."""
662
+ try:
663
+ return super().__getattribute__(name)
664
+ except AttributeError as exc:
665
+ if name in self._values:
666
+ return self._values[name]
667
+ raise exc
668
+
669
+ def __setattr__(self, name: str, value: Any) -> None:
670
+ """Set a configuration attribute."""
671
+ if name in self._settings:
672
+ self._values[name] = value
673
+ getattr(self.events, name)()
674
+ else:
675
+ super().__setattr__(name, value)
593
676
 
594
- def __setitem__(self, name: str, value: Any) -> None:
595
- """Set a configuration attribute.
677
+ @classmethod
678
+ def add_setting(cls, name: str, *args: Any, **kwargs: Any) -> None:
679
+ """Register a new config item."""
680
+ setting = Setting(name, *args, **kwargs)
681
+ Config._settings[name] = setting
596
682
 
597
- Args:
598
- name: The name of the attribute to set.
599
- value: The value to give the attribute.
600
683
 
601
- """
602
- setting = self.settings[name]
603
- setting.value = value
604
-
605
-
606
- def add_setting(
607
- name: str,
608
- default: Any,
609
- help_: str,
610
- description: str,
611
- type_: Callable[[Any], Any] | None = None,
612
- action: argparse.Action | str | None = None,
613
- flags: list[str] | None = None,
614
- schema: dict[str, Any] | None = None,
615
- nargs: str | int | None = None,
616
- hidden: FilterOrBool = False,
617
- hooks: list[Callable[[Setting], None]] | None = None,
618
- cmd_filter: FilterOrBool = True,
619
- **kwargs: Any,
620
- ) -> None:
621
- """Register a new config item."""
622
- Config.settings[name] = Setting(
623
- name=name,
624
- default=default,
625
- help_=help_,
626
- description=description,
627
- type_=type_,
628
- action=action,
629
- flags=flags,
630
- schema=schema,
631
- nargs=nargs,
632
- hidden=hidden,
633
- hooks=hooks,
634
- cmd_filter=cmd_filter,
635
- **kwargs,
636
- )
637
-
638
-
639
- # ################################### Settings ####################################
640
-
641
- add_setting(
642
- name="version",
643
- default=False,
644
- flags=["--version", "-V"],
645
- action="version",
646
- hidden=True,
647
- version=f"%(prog)s {__version__}",
648
- help_="Show the version number and exit",
649
- description="""
650
- If set, euporie will print the current version number of the application and exit.
651
- All other configuration options will be ignored.
652
-
653
- .. note::
654
-
655
- This cannot be set in the configuration file or via an environment variable
656
- """,
657
- )
684
+ add_setting = Config.add_setting