euporie 2.8.4__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.
- euporie/console/_commands.py +143 -0
- euporie/console/_settings.py +58 -0
- euporie/console/app.py +25 -71
- euporie/console/tabs/console.py +58 -62
- euporie/core/__init__.py +1 -1
- euporie/core/__main__.py +28 -11
- euporie/core/_settings.py +109 -0
- euporie/core/app/__init__.py +3 -0
- euporie/core/app/_commands.py +95 -0
- euporie/core/app/_settings.py +457 -0
- euporie/core/{app.py → app/app.py} +212 -576
- euporie/core/app/base.py +51 -0
- euporie/core/{current.py → app/current.py} +13 -4
- euporie/core/app/cursor.py +35 -0
- euporie/core/app/dummy.py +12 -0
- euporie/core/app/launch.py +28 -0
- euporie/core/bars/__init__.py +11 -0
- euporie/core/bars/command.py +205 -0
- euporie/core/bars/menu.py +258 -0
- euporie/core/{widgets → bars}/search.py +20 -16
- euporie/core/{widgets → bars}/status.py +6 -23
- euporie/core/clipboard.py +19 -80
- euporie/core/comm/base.py +8 -6
- euporie/core/comm/ipywidgets.py +16 -7
- euporie/core/comm/registry.py +2 -1
- euporie/core/commands.py +10 -20
- euporie/core/completion.py +3 -2
- euporie/core/config.py +368 -341
- euporie/core/convert/__init__.py +0 -30
- euporie/core/convert/datum.py +116 -53
- euporie/core/convert/formats/__init__.py +31 -0
- euporie/core/convert/formats/ansi.py +9 -23
- euporie/core/convert/formats/common.py +11 -23
- euporie/core/convert/formats/html.py +45 -40
- euporie/core/convert/formats/pil.py +1 -1
- euporie/core/convert/formats/png.py +3 -5
- euporie/core/convert/formats/sixel.py +3 -3
- euporie/core/convert/registry.py +4 -6
- euporie/core/convert/utils.py +41 -4
- euporie/core/diagnostics.py +2 -2
- euporie/core/filters.py +98 -40
- euporie/core/format.py +2 -3
- euporie/core/ft/ansi.py +1 -1
- euporie/core/ft/html.py +12 -21
- euporie/core/ft/table.py +1 -3
- euporie/core/ft/utils.py +4 -1
- euporie/core/graphics.py +386 -133
- euporie/core/history.py +2 -2
- euporie/core/inspection.py +3 -2
- euporie/core/io.py +207 -28
- euporie/core/kernel/__init__.py +1 -0
- euporie/core/{kernel.py → kernel/client.py} +45 -108
- euporie/core/kernel/manager.py +114 -0
- euporie/core/key_binding/bindings/__init__.py +1 -8
- euporie/core/key_binding/bindings/basic.py +47 -7
- euporie/core/key_binding/bindings/completion.py +3 -8
- euporie/core/key_binding/bindings/micro.py +1 -6
- euporie/core/key_binding/bindings/mouse.py +2 -2
- euporie/core/key_binding/bindings/terminal.py +193 -0
- euporie/core/key_binding/key_processor.py +43 -2
- euporie/core/key_binding/registry.py +2 -0
- euporie/core/key_binding/utils.py +22 -2
- euporie/core/keys.py +7156 -93
- euporie/core/layout/cache.py +3 -3
- euporie/core/layout/containers.py +48 -4
- euporie/core/layout/decor.py +2 -2
- euporie/core/layout/mouse.py +1 -1
- euporie/core/layout/print.py +2 -1
- euporie/core/layout/scroll.py +39 -34
- euporie/core/log.py +76 -64
- euporie/core/lsp.py +118 -24
- euporie/core/margins.py +1 -1
- euporie/core/path.py +62 -13
- euporie/core/renderer.py +58 -17
- euporie/core/style.py +57 -39
- euporie/core/suggest.py +103 -85
- euporie/core/tabs/__init__.py +32 -0
- euporie/core/tabs/_settings.py +113 -0
- euporie/core/tabs/base.py +80 -470
- euporie/core/tabs/kernel.py +419 -0
- euporie/core/tabs/notebook.py +24 -101
- euporie/core/utils.py +92 -15
- euporie/core/validation.py +1 -1
- euporie/core/widgets/_settings.py +188 -0
- euporie/core/widgets/cell.py +19 -50
- euporie/core/widgets/cell_outputs.py +25 -36
- euporie/core/widgets/decor.py +11 -41
- euporie/core/widgets/dialog.py +62 -27
- euporie/core/widgets/display.py +12 -15
- euporie/core/widgets/file_browser.py +2 -23
- euporie/core/widgets/forms.py +8 -5
- euporie/core/widgets/inputs.py +13 -70
- euporie/core/widgets/layout.py +2 -1
- euporie/core/widgets/logo.py +49 -0
- euporie/core/widgets/menu.py +10 -8
- euporie/core/widgets/pager.py +6 -10
- euporie/core/widgets/palette.py +6 -6
- euporie/hub/app.py +52 -35
- euporie/notebook/_commands.py +24 -0
- euporie/notebook/_settings.py +107 -0
- euporie/notebook/app.py +49 -171
- euporie/notebook/filters.py +1 -1
- euporie/notebook/tabs/__init__.py +46 -7
- euporie/notebook/tabs/_commands.py +714 -0
- euporie/notebook/tabs/_settings.py +32 -0
- euporie/notebook/tabs/display.py +4 -4
- euporie/notebook/tabs/edit.py +11 -44
- euporie/notebook/tabs/json.py +5 -5
- euporie/notebook/tabs/log.py +1 -18
- euporie/notebook/tabs/notebook.py +11 -660
- euporie/notebook/widgets/_commands.py +11 -0
- euporie/notebook/widgets/_settings.py +19 -0
- euporie/notebook/widgets/side_bar.py +14 -34
- euporie/preview/_settings.py +104 -0
- euporie/preview/app.py +6 -31
- euporie/preview/tabs/notebook.py +6 -72
- euporie/web/__init__.py +1 -0
- euporie/web/tabs/__init__.py +14 -0
- euporie/web/tabs/web.py +11 -6
- euporie/web/widgets/__init__.py +1 -0
- euporie/web/widgets/webview.py +5 -15
- {euporie-2.8.4.dist-info → euporie-2.8.6.dist-info}/METADATA +10 -8
- euporie-2.8.6.dist-info/RECORD +175 -0
- {euporie-2.8.4.dist-info → euporie-2.8.6.dist-info}/WHEEL +1 -1
- {euporie-2.8.4.dist-info → euporie-2.8.6.dist-info}/entry_points.txt +2 -2
- {euporie-2.8.4.dist-info → euporie-2.8.6.dist-info}/licenses/LICENSE +1 -1
- euporie/core/launch.py +0 -64
- euporie/core/terminal.py +0 -522
- euporie-2.8.4.dist-info/RECORD +0 -147
- {euporie-2.8.4.data → euporie-2.8.6.data}/data/share/applications/euporie-console.desktop +0 -0
- {euporie-2.8.4.data → euporie-2.8.6.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
|
12
|
-
from functools import partial
|
11
|
+
from functools import cached_property, partial
|
13
12
|
from pathlib import Path
|
14
|
-
from
|
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__
|
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
|
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
|
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
|
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(
|
77
|
+
for _, t, v in ArgparseLexer().get_tokens_unprocessed(
|
78
|
+
message.rstrip("\n")
|
79
|
+
)
|
70
80
|
]
|
71
81
|
),
|
72
82
|
file=file,
|
73
|
-
style=
|
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
|
-
|
148
|
-
|
149
|
-
|
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.
|
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.
|
191
|
+
self.hooks = hooks
|
180
192
|
self.cmd_filter = cmd_filter
|
193
|
+
self.kwargs = kwargs
|
181
194
|
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
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 is 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 is 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 is bool:
|
260
|
-
new = not self.value
|
261
|
-
elif (
|
262
|
-
self.type is 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
|
-
|
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
|
-
#
|
331
|
-
|
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}
|
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
|
-
|
361
|
-
|
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.
|
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
|
-
|
378
|
-
|
379
|
-
|
380
|
-
|
381
|
-
self.
|
382
|
-
|
383
|
-
|
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.
|
374
|
+
self._config_file_path = (user_conf_dir / self._conf_file_name).with_suffix(
|
388
375
|
".json"
|
389
376
|
)
|
390
|
-
self.
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
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
|
-
|
405
|
-
|
406
|
-
|
407
|
-
|
408
|
-
|
409
|
-
|
410
|
-
|
411
|
-
|
412
|
-
|
413
|
-
|
414
|
-
|
415
|
-
|
416
|
-
|
417
|
-
|
418
|
-
|
419
|
-
|
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
|
-
"
|
422
|
-
|
423
|
-
|
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
|
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.
|
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
|
475
|
+
def _load_parser(self) -> argparse.ArgumentParser:
|
437
476
|
"""Construct an :py:class:`ArgumentParser`."""
|
438
477
|
parser = ArgumentParser(
|
439
|
-
description=self.
|
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
|
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.
|
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
|
-
|
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
|
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.
|
475
|
-
if
|
476
|
-
env = f"{__app_name__
|
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__
|
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
|
-
|
499
|
-
|
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
|
538
|
+
def _load_config_file(self) -> dict[str, Any]:
|
514
539
|
"""Attempt to load JSON configuration file."""
|
515
540
|
results = {}
|
516
|
-
assert isinstance(self.
|
517
|
-
if self.
|
518
|
-
with self.
|
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.
|
549
|
+
self._config_file_path,
|
525
550
|
)
|
526
|
-
self.
|
551
|
+
self._valid_user = False
|
527
552
|
else:
|
528
553
|
results.update(json_data)
|
529
554
|
return results
|
530
555
|
|
531
|
-
def
|
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.
|
560
|
+
json_data = self._load_config_file()
|
536
561
|
# Load section for the current app
|
537
|
-
if
|
538
|
-
json_data = json_data.get(
|
539
|
-
|
540
|
-
|
541
|
-
|
542
|
-
|
543
|
-
|
544
|
-
|
545
|
-
|
546
|
-
|
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
|
551
|
-
"""
|
552
|
-
|
553
|
-
|
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
|
-
|
579
|
-
|
580
|
-
|
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
|
-
|
583
|
-
|
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
|
-
|
586
|
-
|
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
|
-
|
589
|
-
|
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
|
-
|
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
|
-
|
595
|
-
|
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
|