diff --git a/.gitignore b/.gitignore index 9a71120..3c2d64a 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,9 @@ MANIFEST *.conf *.cfg +saves/ +settings/ + # Installer logs pip-log.txt pip-delete-this-directory.txt diff --git a/README.md b/README.md index 8f8a903..0fea350 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Currently, Thymus supports only the Juniper Junos OS tree-view configuration (*t ## Requirements +Tested with Python **3.8.10**. + Thymus uses [Textual](https://github.com/Textualize/textual) as its TUI part so all the requirements of the latter are applicable to the former. There are no additional requirements (except your courage for sure). ## Modes @@ -61,6 +63,24 @@ From the Textual documentation: # Usage +## Context vs. global + +When you open a config file you get access to the Working view. This view allows you to navigate through the config file, filter some output, save it, etc. Thymus supports two configurable instances: context and global. The context instance describes the settings of a configuration that is opened in the Working view. Currently, there is only the JunOS context available. The global instance allows you to manipulate settings of Thymus itself. Context's settings are manipulated via the `set` command, global -- the `global set` command. + +The global settings are stored in the `thymus\settings\global.json` file. Thymus creates this directory and file at its startup if it is able to. + +## Global commands list + +- `global show themes` command lists all available highlighting themes. +- `global set theme` command allows you to choose any theme from the list. + +## Context commands list + +- `set` command allows you to configure some system-wide settings for the current context. It supports now: +- - `name` is to set the name of the context. This name is required by the `compare` modificator of the `show` command. +- - `spaces` is to set the indentation length for the `show` command. +- - `encoding` is to set the encoding for the `save` modificator. + ## Junos commands list The behavior of the tool mimics Junos CLI. Some commands are replaced with more handy analogs. @@ -70,11 +90,7 @@ The behavior of the tool mimics Junos CLI. Some commands are replaced with more - `top` command without any arguments sets the current path back to the root. - - `top show` modification allows you to see the full configuration (the configuration of the root). It also supports relative paths as the `show` does. - - `top go` modification allows you to change the current path to any other possible section. -- `up` command without any arguments sets the current path back to the previous section. It supports a number of sections to set back. If the number is bigger than the current depth `up` works as `top`. -- `set` command allows you to configure some system-wide settings for the current screen. Currently, it supports: -- - `name` is to set the name of the screen/context. This name is required by the `compare` modificator of the `show` command. -- - `spaces` is to set the indentation length for the `show` command. -- - `encoding` is to set the encoding for the `save` modificator. +- `up` command without any arguments sets the current path back to the previous section. It supports a number of sections to return. If the number is bigger than the current depth `up` works as `top`. It also supports `show` instead the number, `up show` does not support any arguments or modificators at this moment. And some commands are for the CLI-mode: @@ -145,7 +161,7 @@ The sidebar shows you sections for autocompleting your current input. It works w ## What's next -- Syntax highlighting. +- Configs` analyzing. - Other NOS`es support. ## Feedback diff --git a/release_notes.md b/release_notes.md index 6b09ac7..338bf3c 100644 --- a/release_notes.md +++ b/release_notes.md @@ -2,4 +2,15 @@ * Textual support updated to the latest version (v0.24.1). * The open dialog was redesigned. -* An extended textlog widged's code was simplified with the scroll_end option. \ No newline at end of file +* An extended textlog widged's code was simplified with the scroll_end option. + +# Version 0.1.2-alpha + +* The main menu was redesigned. +* Thymus supports settings and the settings file now. +* User can manipulate the settings via the `global` commands. +* Syntax highlighting. +* The `up show` modificator for the TUI part. +* Fixed bug with double rows of the TreeView in the open dialog. +* Fixed bug with the Input in the open dialog, it supports Enter hit now. +* Fixed bug with ths save modificator, it does not crash the app now. diff --git a/thymus/__init__.py b/thymus/__init__.py index 3657d40..dccccc2 100644 --- a/thymus/__init__.py +++ b/thymus/__init__.py @@ -1 +1 @@ -__version__ = '0.1.1-alpha' +__version__ = '0.1.2-alpha' diff --git a/thymus/__main__.py b/thymus/__main__.py index 03712f8..082a64f 100644 --- a/thymus/__main__.py +++ b/thymus/__main__.py @@ -2,7 +2,7 @@ import sys -from . import __version__ as appver +from . import __version__ as app_ver from . import tuier from . import clier @@ -37,7 +37,7 @@ def main(args: list[str]) -> None: elif args[1] == 'tuier': run_tui() elif args[1] == 'version' or args[1] == 'ver': - print(f'Thymus ver. {appver}') + print(f'Thymus ver. {app_ver}') else: help() else: diff --git a/thymus/app_settings.py b/thymus/app_settings.py new file mode 100644 index 0000000..73e999d --- /dev/null +++ b/thymus/app_settings.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import os +import sys +import json + +from typing import TYPE_CHECKING, Any +from collections import deque +from dataclasses import dataclass + +from pygments.styles import get_all_styles + + +if TYPE_CHECKING: + + if sys.version_info.major == 3 and sys.version_info.minor >= 9: + from collections.abc import Callable, Iterable + else: + from typing import Callable, Iterable + +CONFIG_PATH = 'thymus/settings' +SAVES_PATH = 'thymus/saves' +CONFIG_NAME = 'global.json' +MIN_CODE_WIDTH = 1500 +MAX_CODE_WIDTH = 3000 +DEFAULT_THEME = 'monokai' + + +@dataclass +class SettingsResponse: + status: str # error or success + value: 'Iterable[str]' + +class AppSettings: + __slots__ = ( + '__code_width', + '__theme', + '__errors', + '__is_dir', + ) + + @property + def code_width(self) -> int: + return self.__code_width + + @property + def theme(self) -> str: + return self.__theme + + @property + def styles(self) -> list[str]: + return get_all_styles() + + @code_width.setter + def code_width(self, value: int) -> None: + if type(value) is not int or value > MAX_CODE_WIDTH or value < MIN_CODE_WIDTH: + raise ValueError(f'Code width must be >= {MIN_CODE_WIDTH} and <= {MAX_CODE_WIDTH}.') + self.__code_width = value + + @theme.setter + def theme(self, value: str) -> None: + if value not in self.styles: + raise ValueError('Unknown theme.') + self.__theme = value + + def __init__(self) -> None: + self.__code_width: int = MIN_CODE_WIDTH + self.__theme: str = DEFAULT_THEME + self.__errors: deque[str] = deque() + self.__is_dir: bool = True + self.__load_configuration() + + def __load_configuration(self) -> None: + try: + if not os.path.exists(SAVES_PATH): + os.mkdir(SAVES_PATH) + if not os.path.exists(CONFIG_PATH): + os.mkdir(CONFIG_PATH) + else: + if not os.path.isdir(CONFIG_PATH): + self.__errors.append(f'The directory "{CONFIG_PATH}" cannot be created.') + self.__is_dir = False + return + if not os.path.exists(f'{CONFIG_PATH}/{CONFIG_NAME}'): + self.__save_settings() + else: + with open(f'{CONFIG_PATH}/{CONFIG_NAME}', encoding='utf-8') as f: + data: dict[str, Any] = json.load(f) + self.theme = data['theme'] + self.code_width = int(data['code_width']) + except Exception as err: + if len(err.args): + err_line = ' '.join(err.args) + self.__errors.append(f'Something went wrong: "{err_line}".') + + def __save_settings(self) -> None: + if not self.__is_dir: + return + with open(f'{CONFIG_PATH}/{CONFIG_NAME}', 'w', encoding='utf-8') as f: + data = { + 'code_width': self.code_width, + 'theme': self.theme, + } + json.dump(data, f) + f.flush() + os.fsync(f.fileno()) + + def playback(self, logger: 'Callable[[str], None]') -> None: + while self.__errors: + error = self.__errors.popleft() + logger(error) + + def process_command(self, command: str) -> SettingsResponse: + if not command.startswith('global '): + return SettingsResponse('error', iter(['Unknown global command.'])) + parts = command.split() + if len(parts) < 3: + return SettingsResponse('error', iter(['Incomplete global command.'])) + subcommand = parts[1] + try: + if subcommand == 'show': + if len(parts) > 3: + return SettingsResponse('error', iter(['Too many arguments for `global show` command.'])) + arg = parts[2] + if arg == 'themes': + result: list[str] = [] + result.append('* -- current theme') + for theme in self.styles: + result.append(f'{theme}*' if theme == self.theme else theme) + return SettingsResponse('success', iter(result)) + else: + return SettingsResponse('error', iter(['Unknown argument for `global show` command.'])) + elif subcommand == 'set': + if len(parts) < 4: + return SettingsResponse('error', iter(['Incomplete `global set` command.'])) + arg = parts[2] + if arg == 'theme': + if len(parts) > 4: + return SettingsResponse('error', iter(['Too many arguments for `global set` command.'])) + value = parts[3] + self.theme = value + self.__save_settings() + return SettingsResponse('success', iter([f'+ The theme was changed to {self.theme}.'])) # TEMP + else: + return SettingsResponse('error', iter(['Unknown argument for `global set` command.'])) + else: + return SettingsResponse('error', iter(['Unknown global command.'])) + except ValueError as err: + if len(err.args): + return SettingsResponse('error', iter(err.args)) + return SettingsResponse('error', []) diff --git a/thymus/contexts/__init__.py b/thymus/contexts/__init__.py index ceaadef..8a8120d 100644 --- a/thymus/contexts/__init__.py +++ b/thymus/contexts/__init__.py @@ -1,11 +1,11 @@ __all__ = ( 'Context', + 'ContextResponse', 'JunosContext', ) -__version__ = '0.1.0' - from .context import ( Context, + ContextResponse, JunosContext, ) diff --git a/thymus/contexts/context.py b/thymus/contexts/context.py index f2bc77f..5d8162c 100644 --- a/thymus/contexts/context.py +++ b/thymus/contexts/context.py @@ -20,6 +20,7 @@ from typing import TYPE_CHECKING, NoReturn from collections import deque from functools import reduce +from dataclasses import dataclass if TYPE_CHECKING: @@ -32,6 +33,13 @@ else: from typing import Generator, Iterable, Callable +UP_LIMIT = 8 + + +@dataclass +class ContextResponse: + status: str # error or success + value: 'Iterable[str]' class Context: __slots__ = ( @@ -77,7 +85,7 @@ def encoding(self, value: str) -> None: except LookupError: raise ValueError(f'{value} is not a correct encoding.') - def on_enter(self, value: str) -> 'Iterable[str]': + def on_enter(self, value: str) -> ContextResponse: pass def update_virtual(self, value: str) -> 'Generator[str, None, None]': @@ -133,9 +141,9 @@ def spaces(self, value: int) -> None: raise ValueError('Spaces number can be 2, 4, or 8.') self.__spaces = value - def on_enter(self, value: str) -> 'Iterable[str]': + def on_enter(self, value: str) -> ContextResponse: if not value: - return + return ContextResponse('error', []) args = reduce( lambda acc, x: acc[:-1] + [acc[-1] + [x]] if x != '|' else acc + [[]], shlex.split(value), @@ -153,7 +161,7 @@ def on_enter(self, value: str) -> 'Iterable[str]': return self.__command_up(head) elif command == 'set': return self.__command_set(head) - return [] + return ContextResponse('error', []) def __update_virtual_from_cursor(self, parts: list[str]) -> 'Generator[str, None, None]': if not parts: @@ -248,12 +256,15 @@ def __save(self, args: deque[str], source: 'Iterable[str]') -> NoReturn: # terminating modificator if len(args) == 1 and source: destination = args.pop() - with open(destination, 'w', encoding=self.encoding) as f: - for line in source: - f.write(f'{line}\n') - f.flush() - os.fsync(f.fileno()) - raise FabricException() + try: + with open(destination, 'w', encoding=self.encoding) as f: + for line in source: + f.write(f'{line}\n') + f.flush() + os.fsync(f.fileno()) + raise FabricException() + except FileNotFoundError: + raise FabricException('No such file or directory.') raise FabricException('Incorrect arguments for `save`.') def __count(self, args: deque[str], source: 'Iterable[str]') -> NoReturn: @@ -383,48 +394,48 @@ def __process_fabric( pass except FabricException as err: if len(err.args): - yield err.args[0] + yield f'- {err.args[0]}' # TEMP - def __command_show(self, args: deque[str] = [], mods: list[list[str]] = []) -> 'Iterable[str]': + def __command_show(self, args: deque[str] = [], mods: list[list[str]] = []) -> ContextResponse: if args: if args[0] in ('ver', 'version',): if len(args) > 1: - return iter(['Incorrect arguments for `show version`.']) + return ContextResponse('error', iter(['Incorrect arguments for `show version`.'])) ver = self.__tree['version'] if self.__tree['version'] else 'No version has been detected.' - return iter([ver]) + return ContextResponse('success', iter([ver])) else: if node := search_node(args, self.__cursor): data = lazy_parser(self.content, node['path'], self.delimiter) next(data) if mods: args.append(node['path']) - return self.__process_fabric(mods, data, extra_args=args) + return ContextResponse('success', self.__process_fabric(mods, data, extra_args=args)) else: - return lazy_provide_config(data, block=' ' * self.spaces) + return ContextResponse('success', lazy_provide_config(data, block=' ' * self.spaces)) else: - return iter(['The path is not correct.']) + return ContextResponse('error', iter(['The path is not correct.'])) else: data = iter(self.content) if self.__cursor['name'] != 'root': data = lazy_parser(data, self.__cursor['path'], self.delimiter) next(data) if mods: - return self.__process_fabric(mods, data) + return ContextResponse('success', self.__process_fabric(mods, data)) else: - return lazy_provide_config(data, block=' ' * self.spaces) + return ContextResponse('success', lazy_provide_config(data, block=' ' * self.spaces)) - def __command_go(self, args: deque[str]) -> 'Iterable[str]': + def __command_go(self, args: deque[str]) -> ContextResponse: if not args: - return iter(['Incorrect arguments for `go`.']) + return ContextResponse('error', iter(['Incorrect arguments for `go`.'])) if node := search_node(args, self.__cursor): self.__cursor = node else: - return iter(['The path is not correct.']) - return [] + return ContextResponse('error', iter(['The path is not correct.'])) + return ContextResponse('success', []) - def __command_top(self, args: deque[str] = [], mods: list[list[str]] = []) -> 'Iterable[str]': + def __command_top(self, args: deque[str] = [], mods: list[list[str]] = []) -> ContextResponse: if args and len(args) < 2: - return iter(['Incorrect arguments for `top`.']) + return ContextResponse('error', iter(['Incorrect arguments for `top`.'])) if args: command = args.popleft() if command == 'show': @@ -432,33 +443,42 @@ def __command_top(self, args: deque[str] = [], mods: list[list[str]] = []) -> 'I self.__cursor = self.__tree result = self.__command_show(args, mods) self.__cursor = temp - return result + return result # propagates show's status and value elif command == 'go': temp = self.__cursor self.__cursor = self.__tree - if self.__command_go(args): + result = self.__command_go(args) + if result.status == 'error': self.__cursor = temp + return ContextResponse('error', result.value) else: - return iter(['Incorrect arguments for `top`.']) + return ContextResponse('error', iter(['Incorrect arguments for `top`.'])) else: if self.__cursor['name'] == 'root': - return [] + return ContextResponse('success', []) self.__cursor = self.__tree - return [] + return ContextResponse('success', []) - def __command_up(self, args: deque[str] = []) -> 'Iterable[str]': + def __command_up(self, args: deque[str] = []) -> ContextResponse: if args and len(args) != 1: - return iter(['Incorrect arguments for `up`.']) - if args and len(args[0]) > 2: - return iter(['Incorrect length for `up`.']) + return ContextResponse('error', iter(['Incorrect arguments for `up`.'])) steps_back = 1 if args: - if args[0].isdigit(): - steps_back = int(args[0]) + command = args.popleft() + if command.isdigit(): + steps_back = min(int(command), UP_LIMIT) + elif command == 'show': + if self.__cursor['name'] == 'root': + return ContextResponse('error', iter(['Incorrect arguments for `up`.'])) + temp = self.__cursor + self.__cursor = self.__cursor['parent'] + result = self.__command_show() + self.__cursor = temp + return result # propagates show's status and value else: - return iter(['Incorrect arguments for `up`.']) + return ContextResponse('error', iter(['Incorrect arguments for `up`.'])) if self.__cursor['name'] == 'root': - return [] + return ContextResponse('success', []) node = self.__cursor while steps_back: if node['name'] == 'root': @@ -466,15 +486,15 @@ def __command_up(self, args: deque[str] = []) -> 'Iterable[str]': node = node['parent'] steps_back -= 1 self.__cursor = node - return [] + return ContextResponse('success', []) - def __command_set(self, args: deque[str]) -> 'Iterable[str]': + def __command_set(self, args: deque[str]) -> ContextResponse: if not args: - return iter(['Incorrect arguments for `set`.']) + return ContextResponse('error', iter(['Incorrect arguments for `set`.'])) command = args.popleft() if command in ('name', 'spaces', 'encoding',): if len(args) != 1: - return iter([f'Incorrect arguments for `set {command}`.']) + return ContextResponse('error', iter([f'Incorrect arguments for `set {command}`.'])) value = args.pop() try: if command == 'name': @@ -485,9 +505,9 @@ def __command_set(self, args: deque[str]) -> 'Iterable[str]': self.encoding = value except ValueError as err: if len(err.args): - return iter(err.args) + return ContextResponse('error', iter(err.args)) else: - return iter([f'The {command} was successfully set.']) + return ContextResponse('success', iter([f'+ The {command} was successfully set.'])) # TEMP else: - return iter(['Unknow argument for `set`.']) - return [] + return ContextResponse('error', iter(['Unknow argument for `set`.'])) + return ContextResponse('error', []) diff --git a/thymus/lexers/__init__.py b/thymus/lexers/__init__.py new file mode 100644 index 0000000..5a91c84 --- /dev/null +++ b/thymus/lexers/__init__.py @@ -0,0 +1,12 @@ +__all__ = ( + 'IPV4_REGEXP', + 'IPV6_REGEXP', + 'JunosLexer', +) + + +from .common.regexps import ( + IPV4_REGEXP, + IPV6_REGEXP, +) +from .jlexer.jlexer import JunosLexer diff --git a/thymus/lexers/common/regexps.py b/thymus/lexers/common/regexps.py new file mode 100644 index 0000000..4e3b0e9 --- /dev/null +++ b/thymus/lexers/common/regexps.py @@ -0,0 +1,19 @@ +PREFIX_LEN = r'(?:/\d{1,3})?' + +IPV6_LINK_LOCAL = r'fe80:(?::[a-f0-9]{1,4}){0,4}%[0-9a-z]+' + PREFIX_LEN +IPV6_V4_EMBEDDED = r'(?:[a-f0-9]{1,4}:){1,4}:(?:\d{1,3}\.){3}\d{1,3}' + PREFIX_LEN +IPV6_V4_MAPPED = r'::(?:ffff:(?:0:)?)?(?:\d{1,3}\.){3}\d{1,3}' + PREFIX_LEN +IPV6_ADDR_1 = r'(?:[a-f0-9]{1,4}:){7}[a-f0-9]{1,4}' + PREFIX_LEN +IPV6_ADDR_2 = r'(?:[a-z0-9]{1,4}:){1,6}:[a-z0-9]{1,4}' + PREFIX_LEN +IPV6_ADDR_3 = r'(?:[a-z0-9]{1,4}:){1,5}(?:[a-f0-9]{1,4}){1,2}' + PREFIX_LEN +IPV6_ADDR_4 = r'(?:[a-z0-9]{1,4}:){1,4}(?:[a-f0-9]{1,4}){1,3}' + PREFIX_LEN +IPV6_ADDR_5 = r'(?:[a-z0-9]{1,4}:){1,3}(?:[a-f0-9]{1,4}){1,4}' + PREFIX_LEN +IPV6_ADDR_6 = r'(?:[a-z0-9]{1,4}:){1,2}(?:[a-f0-9]{1,4}){1,5}' + PREFIX_LEN +IPV6_ADDR_7 = r'[a-f0-9]{1,4}:(?::[a-f0-9]{1,4}){1,6}' + PREFIX_LEN +IPV6_ADDR_8 = r'(?:(?::[a-f0-9]{1,4}){1,7}|:)' + PREFIX_LEN +IPV6_ADDR_9 = r'(?:[0-9a-f]{1,4}:){1,7}:' + PREFIX_LEN + +IPV6_REGEXP = f'{IPV6_LINK_LOCAL}|{IPV6_V4_EMBEDDED}|{IPV6_V4_MAPPED}|{IPV6_ADDR_1}|{IPV6_ADDR_2}|{IPV6_ADDR_3}' +IPV6_REGEXP += f'|{IPV6_ADDR_4}|{IPV6_ADDR_5}|{IPV6_ADDR_6}|{IPV6_ADDR_7}|{IPV6_ADDR_8}|{IPV6_ADDR_9}' + +IPV4_REGEXP = r'(?:\d{1,3}\.){3}\d{1,3}' + PREFIX_LEN # SIMPLE ENOUGH diff --git a/thymus/lexers/jlexer/jlexer.py b/thymus/lexers/jlexer/jlexer.py new file mode 100644 index 0000000..9511a11 --- /dev/null +++ b/thymus/lexers/jlexer/jlexer.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +from ..common.regexps import IPV4_REGEXP, IPV6_REGEXP + +from pygments.lexer import RegexLexer, bygroups +from pygments.token import ( + Text, + Generic, + Whitespace, + Comment, + Keyword, + Name, + Operator, + Number, +) + +from re import MULTILINE, IGNORECASE + + +class JunosLexer(RegexLexer): + flags = IGNORECASE | MULTILINE + tokens = { + 'root': [ + # STANDALONE COMMENT + ( + r'(\s*)(##? [^\n]+)', + bygroups( + Whitespace, + Comment + ) + ), + # STANDALONE ANNOTATION + ( + r'(\s*)(/\*[^\*]+\*/)(\n)', + bygroups( + Whitespace, + Comment, + Operator.Word + ) + ), + # DIFF/COMPARE + + ( + r'(\s*)(\+)(\s)(.+\n)', + bygroups( + Whitespace, + Generic.Inserted, + Whitespace, + Generic.Inserted + ) + ), + # DIFF/COMPARE - + ( + r'(\s*)(-)(\s)(.+\n)', + bygroups( + Whitespace, + Generic.Deleted, + Whitespace, + Generic.Deleted + ) + ), + # INACTIVE + ( + r'(\s*)(inactive: )(?=[^\n;\s])', + bygroups( + Whitespace, + Name.Constant + ), + '#push' + ), + # PROTECTED + ( + r'(\s*)(protected: )(?=[^\n;\s])', + bygroups( + Whitespace, + Whitespace + ), + '#push' + ), + # SPECIAL: DESCRIPTION + ( + r'(\s*)(description)(\s)(.+)(;\n)', + bygroups( + Whitespace, + Keyword, + Whitespace, + Text, + Operator.Word + ) + ), + # SPECIAL: DISABLE + ( + r'(\s*)(disable)(;\n)', + bygroups( + Whitespace, + Name.Constant, + Operator.Word + ) + ), + # HANDLING TOKENS THROUGH THE STAGER + # IPV4 STAGER + ( + fr'(\s*)({IPV4_REGEXP})', + bygroups( + Whitespace, + Whitespace + ), + 'stager' + ), + # IPV6 STAGER + ( + rf'(\s*)({IPV6_REGEXP})', + bygroups( + Whitespace, + Whitespace + ), + 'stager' + ), + # "TEXT" + ( + r'(\s*)((?<=\s)".+"(?=;|\s))', + bygroups( + Whitespace, + Text + ), + 'stager' + ), + # NUMBER OR BANDWIDTH + ( + r'(\s*)(\d+[mkg]?)(;\n)', + bygroups( + Whitespace, + Number, + Operator.Word + ) + ), + # THE REST (REGULAR) + ( + r'(\s*)([a-z0-9-_\./\*,$]+)', + bygroups( + Whitespace, + Keyword + ), + 'stager' + ), + # END OF A SECTION (WITH A POSSIBLE INLINE COMMENT) + ( + r'(\s*)(\})(\s##\s[^\n]+)?(\n)', + bygroups( + Whitespace, + Operator.Word, + Comment, + Whitespace + ) + ), + ], + 'stager': [ + # ASTERISK SECTIONS (e.g., unit *) + ( + r'(\s)(\*)', + bygroups( + Whitespace, + Keyword + ), + '#push' + ), + # START OF A SQUARE BLOCK + ( + r'(\s)(\[)(\s)', + bygroups( + Whitespace, + Operator.Word, + Whitespace + ), + '#push' + ), + # IPV4 ADDRESS OR PREFIX + ( + rf'(\s)?({IPV4_REGEXP})', + bygroups( + Whitespace, + Whitespace + ), + '#push' + ), + # IPV6 ADDRESS OR PREFIX + ( + rf'(\s)?({IPV6_REGEXP})', + bygroups( + Whitespace, + Whitespace + ), + '#push' + ), + # NUMBER OF BANDWIDTH + ( + r'(\s)?(\d+[mkg]?(?=;|\s))', + bygroups( + Whitespace, + Number + ), + '#push' + ), + # LINKS, IFLS, RIBS, etc. + ( + r'(\s)?([a-z0-9-_/]+\.(?:[a-z0-9-_/]+\.)*[a-z0-9-]+)', + bygroups( + Whitespace, + Name.Tag + ), + '#push' + ), + # "TEXT" (NOT GREEDY) + ( + r'(\s*)((?<=\s)".+?"(?=;|\s))', + bygroups( + Whitespace, + Text + ), + '#push' + ), + # "TEXT" IN A SQUARES BLOCK + ( + r'(\s*)("[^"]+")(\s)', + bygroups( + Whitespace, + Text, + Whitespace + ), + '#push' + ), + # INLINE ANNOTATIONS + ( + r'(\s)?(/\*[^\*]+\*/)(?:(\s)(\}))?', + bygroups( + Whitespace, + Comment, + Whitespace, + Operator.Word + ), + '#push' + ), + # THE REST (REGULAR) + ( + r'(\s)?([a-z0-9-_\./+=\*:^&$,]+)', + bygroups( + Whitespace, + Keyword.Type + ), + '#push' + ), + # BEGIN OF A SECTION (WITH A POSSIBLE INLINE COMMENT) + ( + r'(\s)?(\{)(\s##\s[^\n]+)?', + bygroups( + Whitespace, + Operator.Word, + Comment + ), + '#pop' + ), + # END OF A SQUARE BLOCK (WITH A POSSIBLE INLINE COMMENT) + ( + r'(\s)?(\]?;)(\s##\s[^\n]+)?', + bygroups( + Whitespace, + Operator.Word, + Comment + ), + '#pop' + ), + ], + } diff --git a/thymus/parsers/jparser/jparser.py b/thymus/parsers/jparser/jparser.py index 55fee5e..06b0909 100644 --- a/thymus/parsers/jparser/jparser.py +++ b/thymus/parsers/jparser/jparser.py @@ -188,10 +188,11 @@ def construct_tree(data: list[str], delimiter='^') -> Root: ''' root = Root(name='root', version='', children=[], stubs=[], delimiter=delimiter) current_node = root + section_regexp = r'^[^{]+{(?:\s##\s[^\n]+)?$' for line in data: stripped = line.strip() if '{' in stripped and '}' not in stripped and ';' not in stripped: - if not re.match(r'^(?:.+\s){1,2}{(?:\s##\s.+)?$', stripped, re.I): + if not re.match(section_regexp, stripped, re.I) and not re.search(r'is not defined$', stripped): raise Exception('Incorrect configuration format detected.') section_name = stripped[:-2] # skip ' {' in the end of the line node = Node( diff --git a/thymus/tui/__init__.py b/thymus/tui/__init__.py index 7cd41c6..79fc9a0 100644 --- a/thymus/tui/__init__.py +++ b/thymus/tui/__init__.py @@ -2,6 +2,4 @@ 'OpenDialog', ) -__version__ = '0.1.1' - from .open_dialog import OpenDialog diff --git a/thymus/tui/extended_input.py b/thymus/tui/extended_input.py index 20c3b87..00edf52 100644 --- a/thymus/tui/extended_input.py +++ b/thymus/tui/extended_input.py @@ -2,7 +2,7 @@ import sys -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from asyncio import create_task, CancelledError @@ -27,12 +27,15 @@ class ExtendedInput(Input): - current_task: var['Task | None'] = var(None) + current_task: var['Optional[Task]'] = var(None) async def action_submit(self) -> None: if not self.screen.context: return - if out := self.screen.context.on_enter(self.value): + if self.value.startswith('global '): + out = self.app.settings.process_command(self.value) + self.screen.draw(out) + elif out := self.screen.context.on_enter(self.value): self.screen.draw(out) self.__clear_left_sections() self.screen.update_path() # always updates path due to its possible changes diff --git a/thymus/tui/open_dialog.py b/thymus/tui/open_dialog.py index 0c48759..6f50c1a 100644 --- a/thymus/tui/open_dialog.py +++ b/thymus/tui/open_dialog.py @@ -1,7 +1,8 @@ from __future__ import annotations +from textual import events from textual.screen import Screen -from textual.reactive import Reactive +from textual.reactive import var from textual.containers import ( Horizontal, Vertical, @@ -16,6 +17,7 @@ TextLog, ) from textual.widgets._directory_tree import DirectoryTree, DirEntry +from rich.text import Text from pathlib import Path from typing import TYPE_CHECKING @@ -37,7 +39,7 @@ class OpenDialog(Screen): BINDINGS = [ ('escape', 'app.pop_screen', 'Pop screen') ] - current_path: Reactive[Path] = Reactive(Path.cwd()) + current_path: var[Path] = var(Path.cwd()) def compose(self) -> 'ComposeResult': yield Horizontal( @@ -60,43 +62,18 @@ def compose(self) -> 'ComposeResult': Horizontal( Button('UP', id='od-up-button', variant='primary'), Button('OPEN', id='od-open-button', variant='primary'), + Button('REFRESH', id='od-refresh-button', variant='primary'), Static(id='od-error-caption'), id='od-top-container' ), - DirectoryTree(path=str(self.current_path.absolute()), id='od-directory-tree'), - Input(placeholder='filename...', id='od-main-in'), + DirectoryTree(path='./', id='od-directory-tree'), + ODExtendedInput(placeholder='filename...', id='od-main-in'), id='od-right-block' ), ) - # yield Vertical( - # Horizontal( - # Button('UP', id='od-up-button', variant='primary'), - # Button('OPEN', id='od-open-button', variant='primary'), - # ListView( - # ListItem(Label('Juniper JunOS', name='junos')), - # # ListItem(Label('Arista EOS', name='eos')), - # # ListItem(Label('Cisco IOS', name='ios')), - # id='od-nos-switch' - # ), - # ListView( - # ListItem(Label('UTF-8-SIG', name='utf-8-sig')), - # ListItem(Label('UTF-8', name='utf-8')), - # ListItem(Label('CP1251', name='cp1251')), - # id='od-encoding-switch' - # ), - # Static(id='od-error-caption'), - # id='od-top-container' - # ), - # DirectoryTree(path=str(self.current_path.absolute()), id='od-directory-tree'), - # ODExtendedInput(placeholder='filename...', id='od-main-in') - # ) - def on_ready(self) -> None: - self.query_one(DirectoryTree).focus() - - def on_show(self) -> None: - # TODO: update DirTree here - pass + def on_mount(self, event: events.Mount) -> None: + self.__navigate_tree(self.current_path) def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == 'od-open-button': @@ -104,6 +81,9 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self.open_file(filename) elif event.button.id == 'od-up-button': self.current_path = self.current_path.parent + self.__navigate_tree(self.current_path) + elif event.button.id == 'od-refresh-button': + self.__navigate_tree(self.current_path) def open_file(self, filename: str) -> None: if not filename: @@ -132,7 +112,7 @@ def open_file(self, filename: str) -> None: except Exception as err: if self.app.default_screen: control = self.app.default_screen.query_one('#main-app-log', TextLog) - control.write(f'Error has occurred: {err}') + control.write(Text(f'Error has occurred: {err}', 'red')) self.app.uninstall_screen(screen_name) else: self.app.push_screen(screen_name) @@ -140,7 +120,7 @@ def open_file(self, filename: str) -> None: control = self.app.default_screen.query_one('#main-screens-section', ListView) control.append(ListItem(Label(filename, name=screen_name))) - def watch_current_path(self, value: Path) -> None: + def __navigate_tree(self, value: Path) -> None: tree = self.query_one(DirectoryTree) label = tree.process_label(str(value.absolute())) data = DirEntry(value.absolute(), True) diff --git a/thymus/tui/styles/main.css b/thymus/tui/styles/main.css index 23ce6e1..ccaa76e 100644 --- a/thymus/tui/styles/main.css +++ b/thymus/tui/styles/main.css @@ -1,11 +1,14 @@ #main-app-log { height: auto; - width: 50%; + overflow-x: hidden; } #main-screens-section { height: auto; - width: 50%; +} + +#main-welcome-out { + dock: top; } /* OPEN DIALOG DATA */ @@ -39,6 +42,10 @@ margin-left: 1; } +#od-refresh-button { + margin-left: 1; +} + /* WS DATA */ #ws-main-in { diff --git a/thymus/tui/working_screen.py b/thymus/tui/working_screen.py index 2e382c0..a09853d 100644 --- a/thymus/tui/working_screen.py +++ b/thymus/tui/working_screen.py @@ -2,7 +2,7 @@ import sys -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from itertools import islice @@ -19,14 +19,18 @@ TextLog, Static, ) +from rich.text import Text +from rich.syntax import Syntax from ..contexts import JunosContext +from ..lexers import JunosLexer from .extended_textlog import ExtendedTextLog from .extended_input import ExtendedInput if TYPE_CHECKING: - from ..contexts import Context + from ..app_settings import SettingsResponse + from ..contexts import Context, ContextResponse from textual.app import ComposeResult @@ -43,8 +47,8 @@ class WorkingScreen(Screen): filename: var[str] = var('') nos_type: var[str] = var('') encoding: var[str] = var('') - context: var['Context | None'] = var(None) - draw_data: var['Iterable[str]'] = var([]) + context: var['Optional[Context]'] = var(None) + draw_data: var['Optional[ContextResponse]'] = var(None) def __init__(self, filename: str, nos_type: str, encoding: str, *args, **kwags) -> None: super().__init__(*args, **kwags) @@ -91,32 +95,57 @@ def pre_process_nos(self) -> None: else: raise Exception(f'Unsupported NOS {self.nos_type}.') - def draw(self, out: 'Iterable[str]' = []) -> None: + def __draw(self, status: str, value: 'Iterable[str]', multiplier: int = 1) -> None: control = self.query_one('#ws-main-out', TextLog) - height = control.size.height - if out: - control.clear() - control.scroll_home(animate=False) - self.draw_data = out - if not self.draw_data: + theme = self.app.settings.theme + code_width = self.app.settings.code_width + if not control: return - multiplier = 2 if out else 1 - for line in islice(self.draw_data, height * multiplier): + height = control.size.height + for line in islice(value, height * multiplier): if not line: continue if line[-1] == '\n': line = line[:-1] - control.write(line, scroll_end=False) + if status == 'success': + control.write( + Syntax(line, lexer=JunosLexer(), theme=theme, code_width=code_width), + scroll_end=False + ) + else: + control.write(Text(line, style='red'), scroll_end=False) status_bar = self.query_one('#ws-status-bar') - status = 'Spaces: {SPACES} Lines: {LINES} {ENCODING} {FILENAME}'.format( + if not status_bar: + return + bottom_state = 'Spaces: {SPACES} Lines: {LINES} Theme: {THEME} {ENCODING} {FILENAME}'.format( SPACES=self.context.spaces, LINES=len(self.context.content), + THEME=theme.upper(), ENCODING=self.context.encoding.upper(), FILENAME=self.filename ) if context_name := self.context.name: - status = f'Context: {context_name} ' + status - status_bar.update(status) + bottom_state = f'Context: {context_name} ' + bottom_state + status_bar.update(bottom_state) + + def draw(self, data: 'Optional[ContextResponse]' = None) -> None: + multiplier: int = 1 + if data: + # renew the self.draw_data + if data.value: + multiplier = 2 + control = self.query_one('#ws-main-out', TextLog) + control.clear() + control.scroll_home(animate=False) + self.draw_data = data + else: + # nothing to draw + return + else: + if not self.draw_data or not self.draw_data.value: + # nothing to draw again + return + self.__draw(self.draw_data.status, self.draw_data.value, multiplier) def update_path(self) -> None: control = self.query_one('#ws-path-line', Static) diff --git a/thymus/tuier.py b/thymus/tuier.py index bf5cb63..795ef4a 100644 --- a/thymus/tuier.py +++ b/thymus/tuier.py @@ -7,12 +7,14 @@ TextLog, ListView, ) -from textual.containers import Horizontal +from textual.containers import Horizontal, Vertical from textual.reactive import var +from rich.text import Text -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional -from . import __version__ as appver +from . import __version__ as app_ver +from .app_settings import AppSettings from .tui import OpenDialog @@ -21,7 +23,7 @@ from textual.screen import Screen -class MExtendedListView(ListView): +class MExtendedListView(ListView, can_focus=True): def action_select_cursor(self) -> None: if selected := self.highlighted_child: if value := selected.children[0].name: @@ -38,20 +40,34 @@ class TThymus(App): ('ctrl+d', 'dark_mode', 'Toggle dark mode'), ('ctrl+s', 'main_screen', 'Switch to main'), ] - default_screen: var['Screen | None'] = var(None) + default_screen: var['Optional[Screen]'] = var(None) + settings: var[AppSettings] = var(AppSettings()) + + def __log_error(self, error: str) -> None: + if error and (log := self.query_one('#main-app-log', TextLog)): + log.write(Text(error, style='red')) def compose(self) -> 'ComposeResult': yield Footer() - yield Static(f'Thymus ver. {appver}', id='main-welcome-out') + yield Static(Text(f'Thymus ver. {app_ver}.', style='green italic'), id='main-welcome-out') yield Horizontal( - TextLog(id='main-app-log'), - MExtendedListView(id='main-screens-section'), + Vertical( + Static('Application log:'), + TextLog(id='main-app-log'), + ), + Vertical( + Static('Open contexts list (select one and press Enter):'), + MExtendedListView(id='main-screens-section'), + ), id='main-middle-container' ) def on_compose(self) -> None: self.default_screen = self.screen + def on_ready(self) -> None: + self.settings.playback(self.__log_error) + def action_main_screen(self) -> None: if self.default_screen: self.push_screen(self.default_screen)