hypershell 2.6.4__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.
hypershell/config.py ADDED
@@ -0,0 +1,440 @@
1
+ # SPDX-FileCopyrightText: 2025 Geoffrey Lentner
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Manage configuration."""
5
+
6
+
7
+ # type annotations
8
+ from __future__ import annotations
9
+ from typing import Any
10
+
11
+ # standard libs
12
+ import os
13
+ import io
14
+ import sys
15
+ import json
16
+ import contextlib
17
+ import subprocess
18
+
19
+ # external libs
20
+ import tomlkit
21
+ from pygments.styles import STYLE_MAP as CONSOLE_THEMES
22
+ from cmdkit.app import Application, ApplicationGroup
23
+ from cmdkit.cli import Interface, ArgumentError
24
+ from cmdkit.config import ConfigurationError
25
+ from rich.console import Console
26
+ from rich.syntax import Syntax
27
+
28
+ # internal libs
29
+ from hypershell.core.platform import path
30
+ from hypershell.core.types import smart_coerce
31
+ from hypershell.core.logging import Logger
32
+ from hypershell.core.exceptions import get_shared_exception_mapping
33
+ from hypershell.core.config import (load_file, update, ACTIVE_CONFIG_VARS,
34
+ default as default_config, config as full_config)
35
+
36
+ # public interface
37
+ __all__ = ['ConfigApp', ]
38
+
39
+ # initialize logger
40
+ log = Logger.with_name(__name__)
41
+
42
+
43
+ EDIT_PROGRAM = 'hs config edit'
44
+ EDIT_SYNOPSIS = f'{EDIT_PROGRAM} [-h] [--system | --user | --local]'
45
+ EDIT_USAGE = f"""\
46
+ Usage:
47
+ {EDIT_SYNOPSIS}
48
+
49
+ Edit configuration with default editor.
50
+ The EDITOR/VISUAL environment variable must be set.\
51
+ """
52
+
53
+ EDIT_HELP = f"""\
54
+ {EDIT_USAGE}
55
+
56
+ Options:
57
+ --system Edit system configuration.
58
+ --user Edit user configuration. (default)
59
+ --local Edit local configuration.
60
+ -h, --help Show this message and exit.\
61
+ """
62
+
63
+
64
+ class ConfigEditApp(Application):
65
+ """Edit configuration with default editor."""
66
+
67
+ interface = Interface(EDIT_PROGRAM, EDIT_USAGE, EDIT_HELP)
68
+
69
+ site_name: str = 'user'
70
+ site_interface = interface.add_mutually_exclusive_group()
71
+ site_interface.add_argument('--system', action='store_const', const='system', dest='site_name')
72
+ site_interface.add_argument('--user', action='store_const', const='user', dest='site_name')
73
+ site_interface.add_argument('--local', action='store_const', const='local', dest='site_name')
74
+
75
+ exceptions = {
76
+ **get_shared_exception_mapping(__name__)
77
+ }
78
+
79
+ def run(self: ConfigEditApp) -> None:
80
+ """Business logic for `config edit`."""
81
+
82
+ editor = os.getenv('EDITOR', os.getenv('VISUAL', None))
83
+ if not editor:
84
+ raise RuntimeError('EDITOR or VISUAL environment variable not defined')
85
+
86
+ config_path = path[self.site_name].config
87
+ os.makedirs(os.path.dirname(config_path), exist_ok=True)
88
+
89
+ log.debug(f'Opening {config_path}')
90
+ log.debug(f'Editor: {editor}')
91
+ subprocess.run([editor, config_path])
92
+
93
+
94
+ GET_PROGRAM = 'hs config get'
95
+ GET_SYNOPSIS = f'{GET_PROGRAM} [-h] SECTION[...].VAR [-x] [-r] [--system | --user | --local | --default]'
96
+ GET_USAGE = f"""\
97
+ Usage:
98
+ {GET_SYNOPSIS}
99
+ Get configuration option.\
100
+ """
101
+
102
+ GET_HELP = f"""\
103
+ {GET_USAGE}
104
+
105
+ If source is not specified, the output is the merged configuration
106
+ from all sources. Use `hs config which` to see where a specific
107
+ option originates from.
108
+
109
+ If a single value is requested, use -r/--raw to strip formatting.
110
+
111
+ Arguments:
112
+ SECTION[...].VAR Path to variable (default: '.').
113
+
114
+ Options:
115
+ --system Load from system configuration.
116
+ --user Load from user configuration.
117
+ --local Load from local configuration.
118
+ --default Load from default configuration.
119
+ -x, --expand Expand variable.
120
+ -r, --raw Disable formatting on single value output.
121
+ -h, --help Show this message and exit.\
122
+ """
123
+
124
+
125
+ class ConfigGetApp(Application):
126
+ """Get configuration option."""
127
+
128
+ interface = Interface(GET_PROGRAM, GET_USAGE, GET_HELP)
129
+
130
+ varpath: str = None
131
+ interface.add_argument('varpath', nargs='?', default='.')
132
+
133
+ site_name: str = None
134
+ site_interface = interface.add_mutually_exclusive_group()
135
+ site_interface.add_argument('--system', action='store_const', const='system', dest='site_name')
136
+ site_interface.add_argument('--user', action='store_const', const='user', dest='site_name')
137
+ site_interface.add_argument('--local', action='store_const', const='local', dest='site_name')
138
+ site_interface.add_argument('--default', action='store_const', const='default', dest='site_name')
139
+
140
+ expand: bool = False
141
+ interface.add_argument('-x', '--expand', action='store_true')
142
+
143
+ raw_mode: bool = False
144
+ interface.add_argument('-r', '--raw', action='store_true', dest='raw_mode')
145
+
146
+ # Hidden options used as helpers for completion script
147
+ list_available: bool = False
148
+ list_console_themes: bool = False
149
+ completion_interface = interface.add_mutually_exclusive_group()
150
+ completion_interface.add_argument('--list-available', action='version', version=' '.join(ACTIVE_CONFIG_VARS))
151
+ completion_interface.add_argument('--list-console-themes', action='version',
152
+ version=' '.join(list(CONSOLE_THEMES)))
153
+
154
+ exceptions = {
155
+ **get_shared_exception_mapping(__name__)
156
+ }
157
+
158
+ def run(self: ConfigGetApp) -> None:
159
+ """Business logic for `config get`."""
160
+
161
+ if self.site_name is None:
162
+ config_path = 'configuration' # Note: not meaningful for merged configuration
163
+ config = full_config
164
+ elif self.site_name == 'default':
165
+ config_path = 'default'
166
+ config = default_config
167
+ else:
168
+ config_path = path[self.site_name].config
169
+ if os.path.exists(config_path):
170
+ config = load_file(config_path)
171
+ else:
172
+ raise ConfigurationError(f'{config_path} does not exist')
173
+
174
+ if self.varpath == '.':
175
+ self.print_output(config)
176
+ return
177
+
178
+ if '.' not in self.varpath:
179
+ if self.varpath in config:
180
+ self.print_output(config[self.varpath])
181
+ return
182
+ else:
183
+ raise ConfigurationError(f'"{self.varpath}" not found in {config_path}')
184
+
185
+ if self.varpath.startswith('.'):
186
+ raise ConfigurationError(f'Section name cannot start with "."')
187
+
188
+ section, *subsections, variable = self.varpath.split('.')
189
+ if section not in config:
190
+ raise ConfigurationError(f'"{section}" is not a section in {config_path}')
191
+
192
+ config_section = config[section]
193
+ if subsections:
194
+ subpath = f'{section}'
195
+ try:
196
+ for subsection in subsections:
197
+ subpath += f'.{subsection}'
198
+ if not isinstance(config_section[subsection], dict):
199
+ raise ConfigurationError(f'"{subpath}" not a section in {config_path}')
200
+ else:
201
+ config_section = config_section[subsection]
202
+ except KeyError as error:
203
+ raise ConfigurationError(f'"{subpath}" not found in {config_path}') from error
204
+
205
+ if self.expand:
206
+ try:
207
+ value = getattr(config_section, variable)
208
+ except AttributeError as error:
209
+ raise ConfigurationError('') from error
210
+ if value is None:
211
+ raise ConfigurationError(f'"{variable}" not found in {config_path}')
212
+ self.print_output(value)
213
+ return
214
+ elif variable in config_section:
215
+ self.print_output(config_section[variable])
216
+ else:
217
+ raise ConfigurationError(f'"{self.varpath}" not found in {config_path}')
218
+
219
+ def print_output(self: ConfigGetApp, value: Any) -> None:
220
+ """Format and print final `value`."""
221
+ value = self.format_output(value)
222
+ if sys.stdout.isatty() and not self.raw_mode:
223
+ output = Syntax(value, 'toml', word_wrap=True,
224
+ theme = full_config.console.theme,
225
+ background_color = 'default')
226
+ Console().print(output)
227
+ else:
228
+ # NOTE: JSON formatting puts quotations - we don't want these on raw output
229
+ print(value.strip('"'), file=sys.stdout, flush=True)
230
+
231
+ def format_output(self: ConfigGetApp, value: Any) -> str:
232
+ """Format `value` as appropriate text."""
233
+ if isinstance(value, dict):
234
+ value = self.format_section(value)
235
+ else:
236
+ value = json.dumps(value) # NOTE: close enough
237
+ return value
238
+
239
+ def format_section(self: ConfigGetApp, value: dict) -> str:
240
+ """Format an entire section for output."""
241
+ if self.varpath == '.':
242
+ return tomlkit.dumps(value)
243
+ else:
244
+ return tomlkit.dumps({self.varpath: value})
245
+
246
+
247
+ SET_PROGRAM = 'hs config set'
248
+ SET_SYNOPSIS = f'{SET_PROGRAM} [-h] SECTION[...].VAR VALUE [--system | --user | --local]'
249
+ SET_USAGE = f"""\
250
+ Usage:
251
+ {SET_SYNOPSIS}
252
+ Set configuration option.\
253
+ """
254
+
255
+ SET_HELP = f"""\
256
+ {SET_USAGE}
257
+
258
+ Arguments:
259
+ SECTION[...].VAR Path to variable.
260
+ VALUE Value to be set.
261
+
262
+ Options:
263
+ --system Apply to system configuration.
264
+ --user Apply to user configuration. (default)
265
+ --local Apply to local configuration.
266
+ -h, --help Show this message and exit.\
267
+ """
268
+
269
+
270
+ class ConfigSetApp(Application):
271
+ """Set configuration option."""
272
+
273
+ interface = Interface(SET_PROGRAM, SET_USAGE, SET_HELP)
274
+
275
+ varpath: str = None
276
+ interface.add_argument('varpath', metavar='VAR')
277
+
278
+ value: str = None
279
+ interface.add_argument('value', type=smart_coerce)
280
+
281
+ site_name: str = 'user'
282
+ site_interface = interface.add_mutually_exclusive_group()
283
+ site_interface.add_argument('--user', action='store_const', const='user', dest='site_name', default=site_name)
284
+ site_interface.add_argument('--system', action='store_const', const='system', dest='site_name')
285
+ site_interface.add_argument('--local', action='store_const', const='local', dest='site_name')
286
+
287
+ exceptions = {
288
+ **get_shared_exception_mapping(__name__)
289
+ }
290
+
291
+ def run(self: ConfigSetApp) -> None:
292
+ """Business logic for `config set`."""
293
+
294
+ if '.' not in self.varpath:
295
+ raise ArgumentError('Missing section in variable path')
296
+
297
+ section, *subsections, variable = self.varpath.split('.')
298
+
299
+ config = {section: {}}
300
+ config_section = config[section]
301
+ for subsection in subsections:
302
+ if subsection not in config_section:
303
+ config_section[subsection] = dict()
304
+ config_section = config_section[subsection]
305
+
306
+ config_section[variable] = self.value
307
+ update(self.site_name, config)
308
+
309
+
310
+ WHICH_PROGRAM = 'hs config which'
311
+ WHICH_SYNOPSIS = f'{WHICH_PROGRAM} [-h] SECTION[...].VAR [--site]'
312
+ WHICH_USAGE = f"""\
313
+ Usage:
314
+ {WHICH_SYNOPSIS}
315
+ Show origin of configuration option.\
316
+ """
317
+
318
+ WHICH_HELP = f"""\
319
+ {WHICH_USAGE}
320
+
321
+ Arguments:
322
+ SECTION[...].VAR Path to variable.
323
+
324
+ Options:
325
+ --site Output originating site name only.
326
+ -h, --help Show this message and exit.\
327
+ """
328
+
329
+
330
+ class ConfigWhichApp(Application):
331
+ """Show origin of configuration option."""
332
+
333
+ interface = Interface(WHICH_PROGRAM, WHICH_USAGE, WHICH_HELP)
334
+
335
+ varpath: str = None
336
+ interface.add_argument('varpath', metavar='VAR')
337
+
338
+ site_only: bool = False
339
+ interface.add_argument('--site', action='store_true', dest='site_only')
340
+
341
+ exceptions = {
342
+ **get_shared_exception_mapping(__name__)
343
+ }
344
+
345
+ def run(self: ConfigWhichApp) -> None:
346
+ """Business logic for `config which`."""
347
+ try:
348
+ site = full_config.which(*self.varpath.split('.'))
349
+ except KeyError:
350
+ log.critical(f'"{self.varpath}" not found')
351
+ return
352
+ if self.site_only:
353
+ print(site)
354
+ return
355
+ try:
356
+ with contextlib.redirect_stdout(io.StringIO()) as stdout:
357
+ with ConfigGetApp.from_cmdline([self.varpath, '--raw']) as app:
358
+ app.run()
359
+ except ConfigurationError:
360
+ value = 'null'
361
+ else:
362
+ value = stdout.getvalue().strip()
363
+ try:
364
+ with contextlib.redirect_stdout(io.StringIO()) as stdout:
365
+ with ConfigGetApp.from_cmdline([self.varpath, '--raw', '--default']) as app:
366
+ app.run()
367
+ except ConfigurationError:
368
+ default_value = 'null'
369
+ else:
370
+ default_value = stdout.getvalue().strip()
371
+ if '[' in value:
372
+ value = '[...]'
373
+ if '[' in default_value:
374
+ default_value = '[...]'
375
+ if site in ('default', 'logging', ):
376
+ print(f'{value} ({site})')
377
+ elif site == 'env':
378
+ env_varname = 'HYPERSHELL_' + self.varpath.upper().replace('.', '_')
379
+ if value == '[...]':
380
+ for name in full_config.namespaces.env.to_env().flatten(prefix='HYPERSHELL'):
381
+ if name.startswith(env_varname):
382
+ env_varname = name
383
+ print(f'{value} (env: {env_varname} | default: {default_value})')
384
+ else:
385
+ print(f'{value} ({site}: {path[site].config} | default: {default_value})')
386
+
387
+
388
+ if os.name == 'nt':
389
+ CONFIG_PATH_INFO = f"""\
390
+ --system %ProgramData%\\HyperShell\\Config.toml
391
+ --user %AppData%\\HyperShell\\Config.toml
392
+ --local {path.local.config}
393
+ """
394
+ else:
395
+ CONFIG_PATH_INFO = f"""\
396
+ --system /etc/hypershell.toml
397
+ --user ~/.hypershell/config.toml
398
+ --local {path.local.config}
399
+ """
400
+
401
+
402
+ PROGRAM = 'hs config'
403
+ USAGE = f"""\
404
+ Usage:
405
+ {PROGRAM} [-h]
406
+ {GET_SYNOPSIS}
407
+ {SET_SYNOPSIS}
408
+ {EDIT_SYNOPSIS}
409
+ {WHICH_SYNOPSIS}
410
+
411
+ {__doc__}\
412
+ """
413
+
414
+ HELP = f"""\
415
+ {USAGE}
416
+
417
+ Commands:
418
+ get {ConfigGetApp.__doc__}
419
+ set {ConfigSetApp.__doc__}
420
+ edit {ConfigEditApp.__doc__}
421
+ which {ConfigWhichApp.__doc__}
422
+
423
+ Options:
424
+ -h, --help Show this message and exit.
425
+
426
+ Files:
427
+ {CONFIG_PATH_INFO}\
428
+ """
429
+
430
+
431
+ class ConfigApp(ApplicationGroup):
432
+ """Manage configuration."""
433
+
434
+ interface = Interface(PROGRAM, USAGE, HELP)
435
+ interface.add_argument('command')
436
+
437
+ commands = {'get': ConfigGetApp,
438
+ 'set': ConfigSetApp,
439
+ 'edit': ConfigEditApp,
440
+ 'which': ConfigWhichApp, }
@@ -0,0 +1,4 @@
1
+ # SPDX-FileCopyrightText: 2025 Geoffrey Lentner
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Core library components."""