From f86edfd49a28f50e306e0ef0b0e6446599b4ccee Mon Sep 17 00:00:00 2001 From: Igor Malyushkin Date: Sun, 19 Nov 2023 22:01:30 +0400 Subject: [PATCH] ver 0.1.6-alpha --- .gitignore | 1 - README.md | 6 +- poetry.lock | 136 ++--- pyproject.toml | 16 +- thymus/__init__.py | 58 +- thymus/__main__.py | 7 +- thymus/app_settings.py | 511 ------------------ thymus/clier.py | 22 +- thymus/contexts/__init__.py | 23 +- thymus/contexts/context.py | 23 +- thymus/contexts/eos.py | 8 +- thymus/contexts/ios.py | 60 +- thymus/contexts/junos.py | 41 +- thymus/lexers/__init__.py | 7 +- thymus/lexers/common/common.py | 94 +--- thymus/lexers/ios/__init__.py | 4 +- thymus/lexers/ios/ios.py | 234 +------- thymus/lexers/junos/__init__.py | 4 +- thymus/lexers/junos/junos.py | 272 +--------- thymus/misc/utils.py | 1 + thymus/responses/responses.py | 6 +- thymus/settings/__init__.py | 17 + thymus/settings/platform.py | 137 +++++ thymus/settings/settings.py | 447 +++++++++++++++ thymus/templates/context_help.json | 25 + thymus/tui/__init__.py | 4 +- thymus/tui/modals/contexts_modal.py | 4 +- thymus/tui/modals/error_modal.py | 6 +- thymus/tui/modals/logs_modal.py | 2 - thymus/tui/modals/quit_modal.py | 8 +- thymus/tui/net_loader.py | 36 +- thymus/tui/open_dialog.py | 79 ++- thymus/tui/styles/main.css | 190 ------- thymus/tui/working_screen/__init__.py | 4 +- thymus/tui/working_screen/extended_input.py | 12 +- thymus/tui/working_screen/extended_textlog.py | 7 + thymus/tui/working_screen/left_sidebar.py | 31 +- thymus/tui/working_screen/path_bar.py | 21 +- thymus/tui/working_screen/status_bar.py | 10 +- thymus/tui/working_screen/working_screen.py | 62 +-- thymus/tuier.py | 38 +- 41 files changed, 1010 insertions(+), 1664 deletions(-) delete mode 100644 thymus/app_settings.py create mode 100644 thymus/settings/__init__.py create mode 100644 thymus/settings/platform.py create mode 100644 thymus/settings/settings.py create mode 100644 thymus/templates/context_help.json delete mode 100644 thymus/tui/styles/main.css diff --git a/.gitignore b/.gitignore index 4a5232b..4262553 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,6 @@ dist/ #conf conf *.conf -settings/ saves/ log/ diff --git a/README.md b/README.md index 3afbf22..3e9e13c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -![thymus_default_screen](https://github.com/blademd/thymus/assets/1499024/cce6e2e8-02f1-4d46-82c6-daabf51e3d66) +![thymus_default_screen](https://github.com/blademd/thymus/assets/1499024/8c790c6a-7d11-4cd6-8283-52cf29e8472e) # Thymus @@ -33,6 +33,10 @@ Thymus supports: +## Installation + +Use `pip` to install the project right from its sources (e.g., `pip install thymus/` or `python -m pip install thymus/`). + ## Modes Thymus operates in two modes: diff --git a/poetry.lock b/poetry.lock index fb6e4ca..54c3038 100644 --- a/poetry.lock +++ b/poetry.lock @@ -102,35 +102,35 @@ pycparser = "*" [[package]] name = "cryptography" -version = "41.0.4" +version = "41.0.5" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "cryptography-41.0.4-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:80907d3faa55dc5434a16579952ac6da800935cd98d14dbd62f6f042c7f5e839"}, - {file = "cryptography-41.0.4-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:35c00f637cd0b9d5b6c6bd11b6c3359194a8eba9c46d4e875a3660e3b400005f"}, - {file = "cryptography-41.0.4-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cecfefa17042941f94ab54f769c8ce0fe14beff2694e9ac684176a2535bf9714"}, - {file = "cryptography-41.0.4-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e40211b4923ba5a6dc9769eab704bdb3fbb58d56c5b336d30996c24fcf12aadb"}, - {file = "cryptography-41.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:23a25c09dfd0d9f28da2352503b23e086f8e78096b9fd585d1d14eca01613e13"}, - {file = "cryptography-41.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2ed09183922d66c4ec5fdaa59b4d14e105c084dd0febd27452de8f6f74704143"}, - {file = "cryptography-41.0.4-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:5a0f09cefded00e648a127048119f77bc2b2ec61e736660b5789e638f43cc397"}, - {file = "cryptography-41.0.4-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:9eeb77214afae972a00dee47382d2591abe77bdae166bda672fb1e24702a3860"}, - {file = "cryptography-41.0.4-cp37-abi3-win32.whl", hash = "sha256:3b224890962a2d7b57cf5eeb16ccaafba6083f7b811829f00476309bce2fe0fd"}, - {file = "cryptography-41.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:c880eba5175f4307129784eca96f4e70b88e57aa3f680aeba3bab0e980b0f37d"}, - {file = "cryptography-41.0.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:004b6ccc95943f6a9ad3142cfabcc769d7ee38a3f60fb0dddbfb431f818c3a67"}, - {file = "cryptography-41.0.4-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:86defa8d248c3fa029da68ce61fe735432b047e32179883bdb1e79ed9bb8195e"}, - {file = "cryptography-41.0.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:37480760ae08065437e6573d14be973112c9e6dcaf5f11d00147ee74f37a3829"}, - {file = "cryptography-41.0.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b5f4dfe950ff0479f1f00eda09c18798d4f49b98f4e2006d644b3301682ebdca"}, - {file = "cryptography-41.0.4-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7e53db173370dea832190870e975a1e09c86a879b613948f09eb49324218c14d"}, - {file = "cryptography-41.0.4-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5b72205a360f3b6176485a333256b9bcd48700fc755fef51c8e7e67c4b63e3ac"}, - {file = "cryptography-41.0.4-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:93530900d14c37a46ce3d6c9e6fd35dbe5f5601bf6b3a5c325c7bffc030344d9"}, - {file = "cryptography-41.0.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:efc8ad4e6fc4f1752ebfb58aefece8b4e3c4cae940b0994d43649bdfce8d0d4f"}, - {file = "cryptography-41.0.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c3391bd8e6de35f6f1140e50aaeb3e2b3d6a9012536ca23ab0d9c35ec18c8a91"}, - {file = "cryptography-41.0.4-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0d9409894f495d465fe6fda92cb70e8323e9648af912d5b9141d616df40a87b8"}, - {file = "cryptography-41.0.4-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8ac4f9ead4bbd0bc8ab2d318f97d85147167a488be0e08814a37eb2f439d5cf6"}, - {file = "cryptography-41.0.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:047c4603aeb4bbd8db2756e38f5b8bd7e94318c047cfe4efeb5d715e08b49311"}, - {file = "cryptography-41.0.4.tar.gz", hash = "sha256:7febc3094125fc126a7f6fb1f420d0da639f3f32cb15c8ff0dc3997c4549f51a"}, + {file = "cryptography-41.0.5-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:da6a0ff8f1016ccc7477e6339e1d50ce5f59b88905585f77193ebd5068f1e797"}, + {file = "cryptography-41.0.5-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b948e09fe5fb18517d99994184854ebd50b57248736fd4c720ad540560174ec5"}, + {file = "cryptography-41.0.5-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d38e6031e113b7421db1de0c1b1f7739564a88f1684c6b89234fbf6c11b75147"}, + {file = "cryptography-41.0.5-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e270c04f4d9b5671ebcc792b3ba5d4488bf7c42c3c241a3748e2599776f29696"}, + {file = "cryptography-41.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ec3b055ff8f1dce8e6ef28f626e0972981475173d7973d63f271b29c8a2897da"}, + {file = "cryptography-41.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7d208c21e47940369accfc9e85f0de7693d9a5d843c2509b3846b2db170dfd20"}, + {file = "cryptography-41.0.5-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:8254962e6ba1f4d2090c44daf50a547cd5f0bf446dc658a8e5f8156cae0d8548"}, + {file = "cryptography-41.0.5-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:a48e74dad1fb349f3dc1d449ed88e0017d792997a7ad2ec9587ed17405667e6d"}, + {file = "cryptography-41.0.5-cp37-abi3-win32.whl", hash = "sha256:d3977f0e276f6f5bf245c403156673db103283266601405376f075c849a0b936"}, + {file = "cryptography-41.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:73801ac9736741f220e20435f84ecec75ed70eda90f781a148f1bad546963d81"}, + {file = "cryptography-41.0.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3be3ca726e1572517d2bef99a818378bbcf7d7799d5372a46c79c29eb8d166c1"}, + {file = "cryptography-41.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:e886098619d3815e0ad5790c973afeee2c0e6e04b4da90b88e6bd06e2a0b1b72"}, + {file = "cryptography-41.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:573eb7128cbca75f9157dcde974781209463ce56b5804983e11a1c462f0f4e88"}, + {file = "cryptography-41.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0c327cac00f082013c7c9fb6c46b7cc9fa3c288ca702c74773968173bda421bf"}, + {file = "cryptography-41.0.5-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:227ec057cd32a41c6651701abc0328135e472ed450f47c2766f23267b792a88e"}, + {file = "cryptography-41.0.5-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:22892cc830d8b2c89ea60148227631bb96a7da0c1b722f2aac8824b1b7c0b6b8"}, + {file = "cryptography-41.0.5-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:5a70187954ba7292c7876734183e810b728b4f3965fbe571421cb2434d279179"}, + {file = "cryptography-41.0.5-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:88417bff20162f635f24f849ab182b092697922088b477a7abd6664ddd82291d"}, + {file = "cryptography-41.0.5-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c707f7afd813478e2019ae32a7c49cd932dd60ab2d2a93e796f68236b7e1fbf1"}, + {file = "cryptography-41.0.5-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:580afc7b7216deeb87a098ef0674d6ee34ab55993140838b14c9b83312b37b86"}, + {file = "cryptography-41.0.5-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1e91467c65fe64a82c689dc6cf58151158993b13eb7a7f3f4b7f395636723"}, + {file = "cryptography-41.0.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:0d2a6a598847c46e3e321a7aef8af1436f11c27f1254933746304ff014664d84"}, + {file = "cryptography-41.0.5.tar.gz", hash = "sha256:392cb88b597247177172e02da6b7a63deeff1937fa6fec3bbf902ebd75d97ec7"}, ] [package.dependencies] @@ -146,23 +146,6 @@ ssh = ["bcrypt (>=3.1.5)"] test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] -[[package]] -name = "flake8" -version = "6.1.0" -description = "the modular source code checker: pep8 pyflakes and co" -category = "dev" -optional = false -python-versions = ">=3.8.1" -files = [ - {file = "flake8-6.1.0-py2.py3-none-any.whl", hash = "sha256:ffdfce58ea94c6580c77888a86506937f9a1a227dfcd15f245d694ae20a6b6e5"}, - {file = "flake8-6.1.0.tar.gz", hash = "sha256:d5b3857f07c030bdb5bf41c7f53799571d75c4491748a3adcd47de929e34cd23"}, -] - -[package.dependencies] -mccabe = ">=0.7.0,<0.8.0" -pycodestyle = ">=2.11.0,<2.12.0" -pyflakes = ">=3.1.0,<3.2.0" - [[package]] name = "future" version = "0.18.3" @@ -242,18 +225,6 @@ profiling = ["gprof2dot"] rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] -[[package]] -name = "mccabe" -version = "0.7.0" -description = "McCabe checker, plugin for flake8" -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ - {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, - {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, -] - [[package]] name = "mdit-py-plugins" version = "0.4.0" @@ -288,14 +259,14 @@ files = [ [[package]] name = "netmiko" -version = "4.2.0" +version = "4.3.0" description = "Multi-vendor library to simplify legacy CLI connections to network devices" category = "main" optional = false -python-versions = ">=3.7,<4.0" +python-versions = ">=3.8,<4.0" files = [ - {file = "netmiko-4.2.0-py3-none-any.whl", hash = "sha256:8dae36263edc0b5ca5373d3d9ec428f38efd050ecfddac9c0698d0e65082bb3b"}, - {file = "netmiko-4.2.0.tar.gz", hash = "sha256:7adde6fe3ea63336228f49a863650c2d83fb0e680e0f0d158b5b0fb04c4100e1"}, + {file = "netmiko-4.3.0-py3-none-any.whl", hash = "sha256:a873b186e0b61be4a2100eda51e996d917ceddce195b734346b686757e61d324"}, + {file = "netmiko-4.3.0.tar.gz", hash = "sha256:da90f6efdf33b4140eb6cd7f2272773c2ce144fa74ac34d5ecac1b4d4607f1fb"}, ] [package.dependencies] @@ -343,18 +314,6 @@ all = ["gssapi (>=1.4.1)", "invoke (>=2.0)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1 gssapi = ["gssapi (>=1.4.1)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8)"] invoke = ["invoke (>=2.0)"] -[[package]] -name = "pycodestyle" -version = "2.11.1" -description = "Python style guide checker" -category = "dev" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67"}, - {file = "pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f"}, -] - [[package]] name = "pycparser" version = "2.21" @@ -367,32 +326,21 @@ files = [ {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, ] -[[package]] -name = "pyflakes" -version = "3.1.0" -description = "passive checker of Python programs" -category = "dev" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pyflakes-3.1.0-py2.py3-none-any.whl", hash = "sha256:4132f6d49cb4dae6819e5379898f2b8cce3c5f23994194c24b77d5da2e36f774"}, - {file = "pyflakes-3.1.0.tar.gz", hash = "sha256:a0aae034c444db0071aa077972ba4768d40c830d9539fd45bf4cd3f8f6992efc"}, -] - [[package]] name = "pygments" -version = "2.16.1" +version = "2.17.1" description = "Pygments is a syntax highlighting package written in Python." category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "Pygments-2.16.1-py3-none-any.whl", hash = "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692"}, - {file = "Pygments-2.16.1.tar.gz", hash = "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29"}, + {file = "pygments-2.17.1-py3-none-any.whl", hash = "sha256:1b37f1b1e1bff2af52ecaf28cc601e2ef7077000b227a0675da25aef85784bc4"}, + {file = "pygments-2.17.1.tar.gz", hash = "sha256:e45a0e74bf9c530f564ca81b8952343be986a29f6afe7f5ad95c5f06b7bdf5e8"}, ] [package.extras] plugins = ["importlib-metadata"] +windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pynacl" @@ -488,14 +436,14 @@ files = [ [[package]] name = "rich" -version = "13.6.0" +version = "13.7.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" category = "main" optional = false python-versions = ">=3.7.0" files = [ - {file = "rich-13.6.0-py3-none-any.whl", hash = "sha256:2b38e2fe9ca72c9a00170a1a2d20c63c790d0e10ef1fe35eba76e1e7b1d7d245"}, - {file = "rich-13.6.0.tar.gz", hash = "sha256:5c14d22737e6d5084ef4771b62d5d4363165b403455a30a1c8ca39dc7b644bef"}, + {file = "rich-13.7.0-py3-none-any.whl", hash = "sha256:6da14c108c4866ee9520bbffa71f6fe3962e193b7da68720583850cd4548e235"}, + {file = "rich-13.7.0.tar.gz", hash = "sha256:5cb5123b5cf9ee70584244246816e9114227e0b98ad9176eede6ad54bf5403fa"}, ] [package.dependencies] @@ -551,14 +499,14 @@ six = "*" [[package]] name = "textual" -version = "0.40.0" +version = "0.41.0" description = "Modern Text User Interface framework" category = "main" optional = false python-versions = ">=3.7,<4.0" files = [ - {file = "textual-0.40.0-py3-none-any.whl", hash = "sha256:3e98f0c9c9a9361d3077c00e3fc5a708f927dd1ce45a1149eb1ba6945ce9d71c"}, - {file = "textual-0.40.0.tar.gz", hash = "sha256:0fd014f9fab7f6d88167c82f90e115b118b3016b8597281d14c9257967f7812e"}, + {file = "textual-0.41.0-py3-none-any.whl", hash = "sha256:ebf5f04a96721adb8685aae32a98d4a4098dafbfef59b1fb43ca7ac2c1ed5049"}, + {file = "textual-0.41.0.tar.gz", hash = "sha256:73fb675a90ddded17d59ebd864dedaf82a3e7377e68ba1601581281dfd47ea86"}, ] [package.dependencies] @@ -572,14 +520,14 @@ syntax = ["tree-sitter (>=0.20.1,<0.21.0)", "tree_sitter_languages (>=1.7.0)"] [[package]] name = "thymus-ast" -version = "0.1.2" +version = "0.1.5.1" description = "A set of tools to build ASTs for different NOS" category = "main" optional = false python-versions = ">=3.8.1,<4.0.0" files = [ - {file = "thymus_ast-0.1.2-py3-none-any.whl", hash = "sha256:82d7ab525c37c4a115b4d4adb9ebe8ed2bf17d5e903fc6dfd0b62536f702d262"}, - {file = "thymus_ast-0.1.2.tar.gz", hash = "sha256:c9438bd001ac91130ef9b0b41439a336f66630f2222529df8573e7872b6bcca8"}, + {file = "thymus_ast-0.1.5.1-py3-none-any.whl", hash = "sha256:65022d52cae72be40ba62a775e5611b668ba850e2386e237e04c23a13d56eab9"}, + {file = "thymus_ast-0.1.5.1.tar.gz", hash = "sha256:f630a9402a1a57344dadb7827b3fa557a22c6759663a31bdd19d86cf010ae1e8"}, ] [[package]] @@ -628,4 +576,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = "^3.8.1" -content-hash = "5b87d44ac4a85031603cf46a66d3e3bdb52461964bd6462e30e170bc6e40602d" +content-hash = "05b2254e99dbd203017fca131dc28e687cac305c93de2b5d35b69b6c94a02f33" diff --git a/pyproject.toml b/pyproject.toml index 1bd5d85..f0020b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,19 +1,25 @@ [tool.poetry] name = "thymus" -version = "0.1.5" +version = "0.1.6" description = "A browser for network configuration files" authors = ["Igor Malyushkin "] readme = "README.md" [tool.poetry.dependencies] python = "^3.8.1" -textual = "0.40.0" +textual = "0.41.0" netmiko = "^4.2.0" -thymus-ast = "^0.1.2" +thymus-ast = "^0.1.5.1" -[tool.poetry.group.dev.dependencies] -flake8 = "^6.0.0" +[tool.ruff] +line-length = 119 + +[tool.ruff.lint] +ignore = ["E721"] + +[tool.ruff.format] +quote-style = "single" [build-system] requires = ["poetry-core"] diff --git a/thymus/__init__.py b/thymus/__init__.py index 9cfde1a..852c7fb 100644 --- a/thymus/__init__.py +++ b/thymus/__init__.py @@ -1,8 +1,8 @@ -__version__ = '0.1.5-alpha' +__version__ = '0.1.6-alpha' CONFIG_PATH = 'settings/' CONFIG_NAME = 'thymus.json' -WELCOME_TEXT = '''\ +WELCOME_TEXT = """\ ▄▄▄█████▓ ██░ ██▓██ ██▓ ███▄ ▄███▓ █ ██ ██████ ▓ ██▒ ▓▒▓██░ ██▒▒██ ██▒▓██▒▀█▀ ██▒ ██ ▓██▒▒██ ▒ ▒ ▓██░ ▒░▒██▀▀██░ ▒██ ██░▓██ ▓██░▓██ ▒██░░ ▓██▄ @@ -15,10 +15,11 @@ ░ ░ v{} -''' +""" WELCOME_TEXT_LEN = 55 +WRAPPER_DIR = '~/thymus_data/' SAVES_DIR = 'saves/' -SCREENS_SAVES_DIR = 'saves/screenshots/' +SCREENS_DIR = 'screenshots/' LOGGING_CONF_DIR = 'settings/' LOGGING_CONF = LOGGING_CONF_DIR + 'logging.conf' LOGGING_CONF_ENCODING = 'utf-8' @@ -30,52 +31,5 @@ LOGGING_FILE_MAX_INSTANCES = 5 LOGGING_BUF_CAP = 65535 LOGGING_FORMAT = '%(asctime)s %(module)-14s %(levelname)-3s %(message)s' -LOGGING_DEFAULTS = f''' -[loggers] -keys=root - -[handlers] -keys=hand01 - -[formatters] -keys=form01 - -[logger_root] -level={LOGGING_LEVEL} -handlers=hand01 - -[handler_hand01] -class=logging.handlers.RotatingFileHandler -level=NOTSET -formatter=form01 -args=('{LOGGING_FILE}', 'a', {LOGGING_FILE_MAX_SIZE_BYTES}, {LOGGING_FILE_MAX_INSTANCES}, '{LOGGING_FILE_ENCODING}') - -[formatter_form01] -format={LOGGING_FORMAT} -''' N_VALUE_LIMIT = 65535 -CONTEXT_HELP = { - 'header': '[bold yellow]Welcome to {NOS} context.[/]', - 'footer': '\nSome commands may be used with arguments. Please, see the [link=https://github.com/blademd/thymus/wiki]documentation[/link].\nEnter any command in the input field below.', - 'modificators_header': '\nUse any of the next commands for the show command after a pipe symbol:', - 'singletones': { - 'show': 'To show a configuration of a current path use: [bold yellow]{CMDS}[/].', - 'go': 'To switch a current path use: [bold yellow]{CMDS}[/].', - 'top': 'To switch a current path to the top use: [bold yellow]{CMDS}[/].', - 'up': 'To step back one or more sections use: [bold yellow]{CMDS}[/].', - 'help': 'To show these hints use: [bold yellow]{CMDS}[/].', - 'version': 'To show a version of this configuration file use: [bold yellow]{CMDS}[/].', - 'set': 'To configure a current context use: [bold yellow]{CMDS}[/].', - 'global': 'To configure or the application settings use: [bold yellow]{CMDS}[/] and [bold yellow]show[/] or [bold yellow]set[/].', - }, - 'modificators': { - 'filter': 'To filter a line of lines from the output use: [bold yellow]{CMDS}[/].', - 'wildcard': 'To filter a section of lines from the output use: [bold yellow]{CMDS}[/].', - 'stubs': 'To show all finite instructions (stubs) for a current path use: [bold yellow]{CMDS}[/].', - 'sections': 'To list all available nested sections use: [bold yellow]{CMDS}[/].', - 'save': 'To save a content of a current path to a file use: [bold yellow]{CMDS}[/].', - 'count': 'To count lines of a current section use: [bold yellow]{CMDS}[/].', - 'diff': 'To compare two contexts use: [bold yellow]{CMDS}[/].', - 'contains': 'To search a pattern in a configuration use: [bold yellow]{CMDS}[/].', - }, -} +CONTEXT_HELP = 'templates/context_help.json' diff --git a/thymus/__main__.py b/thymus/__main__.py index 082a64f..68d9d35 100644 --- a/thymus/__main__.py +++ b/thymus/__main__.py @@ -7,29 +7,32 @@ from . import clier -HELP_MESSAGE = '''There are two possible options to run the Application: +HELP_MESSAGE = """There are two possible options to run the Application: * -- run the Terminal User Interface version: ** -- python -m thymus ** -- python -m thymus tuier * -- run the Command Line Interface version: ** -- python -m thymus clier -''' +""" def run_tui() -> None: app = tuier.TThymus() app.run() + def run_cli() -> None: try: clier.main() except KeyboardInterrupt: pass + def help() -> None: print(HELP_MESSAGE) + def main(args: list[str]) -> None: if len(args) > 1: if args[1] == 'clier': diff --git a/thymus/app_settings.py b/thymus/app_settings.py deleted file mode 100644 index 9a629dc..0000000 --- a/thymus/app_settings.py +++ /dev/null @@ -1,511 +0,0 @@ -''' -To define a new platform an author is required to decrale a dict with default settings. This dict MUST be named -as DEFAULT_platform, where platform is a name of the new platform. It MUST contain at least `spaces` key with an -appropriate value. -PLATFORMS dict MUST be updated with a key which is equal to the platform name. This key MUST be in a lower case. -For the key there is the only one value -- the previously defined DEFAULT_platform dict. -For AppSettings class a property with the platform name MUST be declared. A function for validating keys of the -platform's dict MUST be declared. This function MUST check all keys that are defined for the new platform's -dict. The name of the function MUST be: __validate_platform_key. -''' - -from __future__ import annotations - -import os -import sys -import json -import logging - -from typing import Any -from copy import copy -from logging.handlers import RotatingFileHandler, BufferingHandler -from logging.config import fileConfig - -if sys.version_info.major == 3 and sys.version_info.minor >= 9: - from collections.abc import Callable -else: - from typing import Callable - -from pygments.styles import get_all_styles # type: ignore - -from . import __version__ as app_ver -from . import ( - CONFIG_PATH, - CONFIG_NAME, - LOGGING_DEFAULTS, - LOGGING_FILE, - LOGGING_FILE_DIR, - LOGGING_FORMAT, - LOGGING_CONF, - LOGGING_CONF_DIR, - LOGGING_CONF_ENCODING, - LOGGING_FILE_MAX_SIZE_BYTES, - LOGGING_FILE_MAX_INSTANCES, - LOGGING_FILE_ENCODING, - LOGGING_BUF_CAP, - N_VALUE_LIMIT, - SAVES_DIR, - SCREENS_SAVES_DIR, -) -from .responses import Response, SettingsResponse - - -DEFAULT_GLOBALS: dict[str, str | int] = { - 'theme': 'monokai', - 'night_mode': 'off', - 'filename_len': 256, - 'sidebar_limit': 64, - 'sidebar_strict_on_tab': 'on', - 'open_dialog_platform': 'junos', -} -DEFAULT_JUNOS: dict[str, str | int] = { - 'spaces': 2, -} -DEFAULT_IOS: dict[str, str | int] = { - 'spaces': 1, - 'heuristics': 'off', - 'base_heuristics': 'on', - 'crop': 'off', - 'promisc': 'off', -} -DEFAULT_EOS: dict[str, str | int] = { - 'spaces': 2, - 'heuristics': 'off', - 'base_heuristics': 'on', - 'crop': 'off', - 'promisc': 'off', -} -DEFAULT_NXOS: dict[str, str | int] = { - 'spaces': 2, - 'heuristics': 'off', - 'base_heuristics': 'on', - 'crop': 'off', -} -PLATFORMS: dict[str, dict[str, str | int]] = { - 'junos': DEFAULT_JUNOS, - 'ios': DEFAULT_IOS, - 'nxos': DEFAULT_NXOS, - 'eos': DEFAULT_EOS, -} - - -class AppSettings: - __slots__: tuple[str, ...] = ( - '__globals', - '__platforms', - '__logger', - '__is_dir', - '__is_alert', - ) - - @property - def globals(self) -> dict[str, str | int]: - return copy(self.__globals) if self.__globals else copy(DEFAULT_GLOBALS) - - @property - def junos(self) -> dict[str, str | int]: - return copy(self.__platforms.get('junos', DEFAULT_JUNOS)) - - @property - def ios(self) -> dict[str, str | int]: - return copy(self.__platforms.get('ios', DEFAULT_IOS)) - - @property - def eos(self) -> dict[str, str | int]: - return copy(self.__platforms.get('eos', DEFAULT_EOS)) - - @property - def nxos(self) -> dict[str, str | int]: - return copy(self.__platforms.get('nxos', DEFAULT_NXOS)) - - @property - def styles(self) -> list[str]: - return get_all_styles() - - @property - def logger(self) -> logging.Logger: - return self.__logger - - @property - def platforms(self) -> list[str]: - return list(self.__platforms.keys()) - - def __init__(self) -> None: - self.__globals: dict[str, str | int] = {} - self.__platforms: dict[str, dict[str, str | int]] = {} - for platform in PLATFORMS: - self.__platforms[platform] = {} - self.__is_dir: bool = True - self.__is_alert: bool = False - self.__init_logging() - self.__process_config() - - def __init_logging(self) -> None: - errors: list[str] = [] - init_from_running: bool = False - if os.path.exists(LOGGING_CONF): - try: - if sys.version_info.major == 3 and sys.version_info.minor >= 10: - fileConfig(LOGGING_CONF, encoding=LOGGING_CONF_ENCODING) - else: - fileConfig(LOGGING_CONF) - except Exception as err: - err_msg = f'Error has occurred during the open of "{LOGGING_CONF}". ' - err_msg += 'Either does not exist or is not well-formatted. ' - err_msg += f'Exception: "{err}".' - errors.append(err_msg) - init_from_running = True - else: - init_from_running = True - try: - if not os.path.exists(LOGGING_CONF_DIR): - os.mkdir(LOGGING_CONF_DIR) - with open(LOGGING_CONF, 'w', encoding=LOGGING_CONF_ENCODING) as f: - f.write(LOGGING_DEFAULTS) - f.flush() - os.fsync(f.fileno()) - except Exception as err: - err_msg = f'Error has occurred during the creating of "{LOGGING_CONF}". ' - err_msg = f'Check the folder "{LOGGING_CONF_DIR}" is here and/or writable. ' - err_msg += f'Exception: "{err}".' - errors.append(err_msg) - self.__logger = logging.getLogger(__name__) - if init_from_running: - try: - self.__logger.setLevel(logging.INFO) - if not os.path.exists(LOGGING_FILE_DIR): - os.mkdir(LOGGING_FILE_DIR) - formatter = logging.Formatter(LOGGING_FORMAT) - file_handler = RotatingFileHandler( - filename=LOGGING_FILE, - maxBytes=LOGGING_FILE_MAX_SIZE_BYTES, - backupCount=LOGGING_FILE_MAX_INSTANCES, - encoding=LOGGING_FILE_ENCODING - ) - file_handler.setFormatter(formatter) - self.__logger.addHandler(file_handler) - except Exception as err: - err_msg = f'Error has occurred during the creating of "{LOGGING_FILE}". ' - err_msg += f'Check the folder "{LOGGING_FILE_DIR}" is here and/or writable. ' - err_msg += f'Exception: "{err}".' - errors.append(err_msg) - try: - formatter = logging.Formatter(LOGGING_FORMAT) - buf_handler = BufferingHandler(LOGGING_BUF_CAP) - buf_handler.setFormatter(formatter) - self.__logger.addHandler(buf_handler) - except Exception as err: - err_msg = 'Error has occurred during the creating of a memory log. ' - err_msg += f'Exception: "{err}".' - errors.append(err_msg) - if errors: - self.__logger.info(f'Thymus {app_ver} started with errors.') - for error in errors: - self.__logger.error(error) - else: - self.__logger.info(f'Thymus {app_ver} started normally.') - - def __process_config(self) -> None: - try: - if not os.path.exists(SAVES_DIR): - self.__logger.info(f'Creating a saves folder: {SAVES_DIR}.') - os.mkdir(SAVES_DIR) - os.mkdir(SCREENS_SAVES_DIR) - else: - if not os.path.exists(SCREENS_SAVES_DIR): - os.mkdir(SCREENS_SAVES_DIR) - if not os.path.isdir(SAVES_DIR): - self.__logger.error(f'There is a path "{SAVES_DIR}", but it is not a folder.') - if not os.path.exists(CONFIG_PATH): - self.__logger.info(f'Creating a settings folder: {CONFIG_PATH}.') - os.mkdir(CONFIG_PATH) - else: - if not os.path.isdir(CONFIG_PATH): - self.__is_dir = False - raise Exception(f'The path "{CONFIG_PATH}" exists, but it is not a folder.') - if os.path.exists(f'{CONFIG_PATH}/{CONFIG_NAME}'): - self.__logger.info(f'Init Thymus from: {CONFIG_NAME}.') - else: - self.__logger.info(f'There is no file: {CONFIG_NAME}. Init Thymus with defaults.') - self.__save_config() - self.__read_config() - except Exception as err: - self.__logger.error(f'{err}') - self.__is_alert = True - - def __read_config(self) -> None: - if not self.__is_dir: - return - self.__logger.debug(f'Loading a configuration from: {CONFIG_PATH}{CONFIG_NAME}.') - data: dict[str, Any] = {} - with open(f'{CONFIG_PATH}{CONFIG_NAME}', encoding='utf-8') as f: - data = json.load(f) - if not data: - raise Exception(f'Reading of the config file "{CONFIG_NAME}" was failed.') - self.validate_keys(DEFAULT_GLOBALS, data, self.__validate_globals) - for platform, store in PLATFORMS.items(): - if platform_data := data.get(platform): - if not hasattr(self, f'_AppSettings__validate_{platform}_key'): - self.__logger.error(f'No validator for {platform.upper()}. Default.') - continue - validator = getattr(self, f'_AppSettings__validate_{platform}_key') - self.validate_keys(store, platform_data, validator, platform) - else: - self.__logger.warning(f'No data for {platform.upper()}. Default.') - - def __save_config(self) -> None: - if not self.__is_dir: - return - self.__logger.debug(f'Saving a configuration into the file: {CONFIG_PATH}{CONFIG_NAME}.') - data: dict[str, str | int | dict[str, str | int]] = {} - for k, v in self.globals.items(): - data[k] = v - for platform, platform_data in PLATFORMS.items(): - data.update( - { - platform: self.__platforms[platform] if self.__platforms.get(platform) else platform_data - } - ) - with open(f'{CONFIG_PATH}{CONFIG_NAME}', 'w', encoding='utf-8') as f: - json.dump(data, f, indent=4) - f.flush() - os.fsync(f.fileno()) - - def validate_keys( - self, - store: dict[str, str | int], - data: dict[str, Any], - validator: Callable[[str, str | int], int | str], - platform: str = '' - ) -> list[str]: - errors: list[str] = [] - for key in store: - # run through all over the available keys - try: - if value := data.get(key): - # if there is a value in the data for a key from the store - # we must check it with the callback - if type(value) is not str and type(value) is not int: - raise Exception(f'Validation error, value type mismatch: {key}->{type(value)}. Default.') - value = validator(key, value) - if platform: - self.__platforms[platform][key] = value - else: - self.__globals[key] = value - else: - err_msg: str = '' - if platform: - err_msg = f'No data for "{key}" {platform} attribute. Default.' - self.__platforms[platform][key] = store[key] - else: - err_msg = f'No data for "{key}" global attribute. Default.' - self.__globals[key] = store[key] - self.__logger.error(err_msg) - errors.append(err_msg) - except Exception: - # makes it default - if platform: - err_msg = f'Incorrect value for the global attribute "{key}": {value}.' - self.__platforms[platform][key] = store[key] - else: - self.__globals[key] = store[key] - err_msg = f'Incorrect value for the {platform.upper()} platform attribute ' - err_msg += f'"{key}": {value}. Default.' - self.__logger.error(err_msg) - errors.append(err_msg) - return errors - - def __validate_globals(self, key: str, value: str | int) -> str | int: - if key == 'theme': - if value not in self.styles: - raise Exception - elif key == 'filename_len': - value = int(value) - if value <= 0 or value > N_VALUE_LIMIT: - raise Exception - elif key == 'sidebar_limit': - value = int(value) - if value <= 0 or value > N_VALUE_LIMIT: - raise Exception - elif key == 'open_dialog_platform': - if value not in PLATFORMS: - raise Exception - elif key in ('sidebar_strict_on_tab', 'night_mode'): - if value not in ('0', '1', 'on', 'off', 0, 1): - raise Exception - else: - self.__logger.warning(f'Unknown global attribute: {key}. Ignore.') - return value - - def __validate_junos_key(self, key: str, value: str | int) -> str | int: - if key == 'spaces': - value = int(value) - if value <= 0: - raise Exception - else: - self.__logger.warning(f'Unknown JunOS attribute: {key}. Ignore.') - return value - - def __validate_ios_key(self, key: str, value: str | int) -> str | int: - if key == 'spaces': - value = int(value) - if value <= 0: - raise Exception - elif key in ('heuristics', 'crop', 'promisc', 'base_heuristics'): - if value not in ('0', '1', 'on', 'off', 0, 1): - raise Exception - else: - self.__logger.warning(f'Unknown IOS attribute: {key}. Ignore.') - return value - - def __validate_eos_key(self, key: str, value: str | int) -> str | int: - if key == 'spaces': - value = int(value) - if value <= 0: - raise Exception - elif key in ('heuristics', 'crop', 'promisc', 'base_heuristics'): - if value not in ('0', '1', 'on', 'off', 0, 1): - raise Exception - else: - self.__logger.warning(f'Unknown EOS attribute: {key}. Ignore.') - return value - - def __validate_nxos_key(self, key: str, value: str | int) -> str | int: - if key == 'spaces': - value = int(value) - if value <= 0: - raise Exception - elif key in ('heuristics', 'crop', 'base_heuristics'): - if value not in ('0', '1', 'on', 'off', 0, 1): - raise Exception - else: - self.__logger.warning(f'Unknown NXOS attribute: {key}. Ignore.') - return value - - def is_bool_set(self, key: str, *, attr_name: str = 'globals') -> bool: - ''' - Be careful! This method considers any integers except 1 as False. 1 is considered as True. - If there is no key or no attribute method returns False! - ''' - if not hasattr(self, attr_name): - return False - attr: dict[str, str | int] = getattr(self, attr_name) - if key not in attr or not attr[key]: - return False - return attr[key] in (1, '1', 'on') - - def process_command(self, command: str) -> Response: - if not command.startswith('global '): - return SettingsResponse.error('Unknown global command.') - parts = command.split() - if len(parts) < 3: - return SettingsResponse.error('Incomplete global command.') - subcommand = parts[1] - if subcommand == 'show': - if len(parts) > 4: - return SettingsResponse.error('Too many arguments for "global show" command.') - arg = parts[2] - if arg == 'themes': - if len(parts) == 4: - return SettingsResponse.error(f'Too many arguments for "global show {arg}" command.') - themes_result = [] - themes_result.append('* -- current theme') - for theme in self.styles: - themes_result.append(f'{theme}*' if theme == self.globals['theme'] else theme) - return SettingsResponse.success(themes_result) - elif arg == 'open_dialog_platform': - if len(parts) == 4: - return SettingsResponse.error(f'Too many arguments for "global show {arg}" command.') - platf_result = self.globals[arg] - return SettingsResponse.success(str(platf_result)) - elif arg in ('filename_len', 'sidebar_limit'): - if len(parts) == 4: - return SettingsResponse.error(f'Too many arguments for "global show {arg}" command.') - num_result = self.globals[arg] - return SettingsResponse.success(str(num_result)) - elif arg in ('sidebar_strict_on_tab', 'night_mode'): - if len(parts) == 4: - return SettingsResponse.error(f'Too many arguments for "global show {arg}" command.') - bool_result = self.is_bool_set(arg) - return SettingsResponse.success(str(bool_result)) - elif arg in PLATFORMS: - if len(parts) == 4: - subarg = parts[3] - if subarg in ('default', 'defaults'): - return SettingsResponse.success(iter(f'{k}: {v}' for k, v in PLATFORMS[arg].items())) - else: - return SettingsResponse.error( - f'Unknown sub-argument for "global show {arg}" command: {subarg}.' - ) - else: - if self.__is_alert: - return SettingsResponse.error('These settings are default. App started abnormally.') - if not self.__platforms[arg]: - return SettingsResponse.success(iter(f'{k}: {v}' for k, v in PLATFORMS[arg].items())) - return SettingsResponse.success(iter(f'{k}: {v}' for k, v in self.__platforms[arg].items())) - else: - return SettingsResponse.error(f'Unknown argument for "global show" command: {arg}.') - elif subcommand == 'set': - if self.__is_alert: - return SettingsResponse.error('The settings system is in read-only mode. App started abnormally.') - if len(parts) < 4: - return SettingsResponse.error('Incomplete "global set" command.') - arg = parts[2] - if arg == 'theme': - if len(parts) > 4: - return SettingsResponse.error('Too many arguments for "global set" command.') - value = parts[3] - if value not in self.styles: - return SettingsResponse.error(f'Unsupported theme: {value}.') - self.__globals[arg] = value - self.__save_config() - return SettingsResponse.success(f'The "{arg}" was changed to: {value}.') - elif arg == 'open_dialog_platform': - if len(parts) > 4: - return SettingsResponse.error('Too many arguments for "global set" command.') - value = parts[3] - if value not in PLATFORMS: - return SettingsResponse.error(f'Unsupported platform: {value}.') - self.__globals[arg] = value - self.__save_config() - return SettingsResponse.success(f'The "{arg}" was changed to: {value}.') - elif arg in ('filename_len', 'sidebar_limit'): - if len(parts) > 4: - return SettingsResponse.error(f'Too many arguments for "global set {arg}" command.') - value = parts[3] - if not value.isdigit() or int(value) <= 0 or int(value) > N_VALUE_LIMIT: - return SettingsResponse.error(f'Value must be in (0; {N_VALUE_LIMIT}].') - self.__globals[arg] = int(value) - self.__save_config() - return SettingsResponse.success(f'The "{arg}" was changed to: {value}.') - elif arg in ('sidebar_strict_on_tab', 'night_mode'): - if len(parts) > 4: - return SettingsResponse.error(f'Too many arguments for "global set {arg}" command.') - value = parts[3] - if value not in ('0', '1', 'on', 'off', 0, 1): - return SettingsResponse.error('Value must be in (0, 1, on, off).') - self.__globals[arg] = value - self.__save_config() - return SettingsResponse.success(f'The "{arg}" was changed to: {value}.') - elif arg in PLATFORMS: - if len(parts) != 5: - return SettingsResponse.error(f'Incorrent number of arguments for "global set {arg}" command.') - subarg = parts[3] - if subarg not in PLATFORMS[arg]: - return SettingsResponse.error( - f'Unknown sub-argument for "global set {arg}" command: {subarg}.' - ) - value = parts[4] - if not hasattr(self, f'_AppSettings__validate_{arg}_key'): - self.__logger.error(f'No validator for {arg}.') - return SettingsResponse.error('Unknown error. See the log file.') - validator = getattr(self, f'_AppSettings__validate_{arg}_key') - r = self.validate_keys({subarg: PLATFORMS[arg][subarg]}, {subarg: value}, validator, arg) - if r: - return SettingsResponse.error(r) - self.__save_config() - return SettingsResponse.success(f'Attribute "{subarg}" was changed to: {value}.') - else: - return SettingsResponse.error(f'Unknown argument for "global set" command: {arg}.') - else: - return SettingsResponse.error(f'Unknown global command: {subcommand}.') diff --git a/thymus/clier.py b/thymus/clier.py index 959a841..82cd4cd 100644 --- a/thymus/clier.py +++ b/thymus/clier.py @@ -6,12 +6,7 @@ from typing import Optional from logging import Logger, getLogger -from .contexts import ( - Context, - JunOSContext, - IOSContext, - EOSContext -) +from .contexts import Context, JunOSContext, IOSContext, EOSContext ENCODING = 'utf-8' NOS_LIST: dict[str, type[Context]] = { @@ -20,9 +15,11 @@ 'eos': EOSContext, } + def err_print(*args, **kwargs) -> None: print(*args, file=sys.stderr, **kwargs) + def usage() -> None: err_print('Usage is not written yet.') @@ -64,11 +61,7 @@ def __open_config(self, args: list[str]) -> None: return None context_name = f'vty{self.__number}' self.__contexts[context_name] = NOS_LIST[nos]( - context_name, - config, - encoding=ENCODING, - settings={}, - logger=self.__logger + context_name, config, encoding=ENCODING, settings={}, logger=self.__logger ) self.__current = self.__contexts[context_name] self.__number += 1 @@ -134,7 +127,12 @@ def main() -> None: print(prompt) continue user_input = user_input.strip() - if user_input in ('exit', 'quit', 'stop', 'logout',): + if user_input in ( + 'exit', + 'quit', + 'stop', + 'logout', + ): print('Goodbye!') break t = time.time() diff --git a/thymus/contexts/__init__.py b/thymus/contexts/__init__.py index 3d71252..6eb5d5d 100644 --- a/thymus/contexts/__init__.py +++ b/thymus/contexts/__init__.py @@ -1,3 +1,9 @@ +from .context import Context +from .junos import JunOSContext +from .ios import IOSContext +from .eos import EOSContext +from .nxos import NXOSContext + __all__ = ( 'Context', 'JunOSContext', @@ -5,20 +11,3 @@ 'EOSContext', 'NXOSContext', ) - -from .context import ( - Context -) -from .junos import ( - JunOSContext, -) -from .ios import ( - IOSContext, -) -from .eos import ( - EOSContext, -) - -from .nxos import ( - NXOSContext -) diff --git a/thymus/contexts/context.py b/thymus/contexts/context.py index a8d3aa3..d7c5bf9 100644 --- a/thymus/contexts/context.py +++ b/thymus/contexts/context.py @@ -7,7 +7,7 @@ from functools import reduce from collections import deque -from typing import Any +from typing import Any, Optional from logging import Logger, getLogger from abc import ABC, abstractmethod @@ -26,7 +26,7 @@ class Context(ABC): - __slots__: tuple[str, ...] = ( + __slots__ = ( '__name', '__content', '__encoding', @@ -138,12 +138,12 @@ def __init__( *, encoding: str, settings: dict[str, str | int], - logger: Logger + logger: Optional[Logger] = None, ) -> None: self.__name = name self.__content = content self.__encoding = encoding - self.__logger = logger if logger else getLogger() + self.__logger = logger if logger else getLogger(__name__) self.__spaces = 2 self.apply_settings(settings) self.__commands_log: deque[str] = deque() @@ -224,9 +224,7 @@ def command_set(self, args: deque[str]) -> Response: return AlertResponse.success(f'The "set {command}" was successfully modified.') def mod_filter( - self, - data: Iterator[str] | Generator[str | Exception, None, None], - args: list[str] + self, data: Iterator[str] | Generator[str | Exception, None, None], args: list[str] ) -> Generator[str | Exception, None, None]: if not data or len(args) != 1: yield FabricException('Incorrect arguments for "filter".') @@ -250,9 +248,7 @@ def mod_filter( yield FabricException() def mod_save( - self, - data: Iterator[str] | Generator[str | Exception, None, None], - args: list[str] + self, data: Iterator[str] | Generator[str | Exception, None, None], args: list[str] ) -> Generator[str | Exception, None, None]: # terminating modificator if len(args) == 1 and data: @@ -280,9 +276,7 @@ def mod_save( yield FabricException('Incorrect arguments for "save".') def mod_count( - self, - data: Iterator[str] | Generator[str | Exception, None, None], - args: list[str] + self, data: Iterator[str] | Generator[str | Exception, None, None], args: list[str] ) -> Generator[str | Exception, None, None]: # terminating modificator if args: @@ -306,7 +300,7 @@ def on_enter(self, value: str) -> Response: args = reduce( # type: ignore lambda acc, x: acc[:-1] + [acc[-1] + [x]] if x != '|' else acc + [[]], # type: ignore shlex.split(value), - [[]] + [[]], ) head = deque(args[0]) # the line before a possible pipe symbol command = head.popleft() @@ -337,5 +331,6 @@ def update_virtual_cursor(self, value: str) -> Generator[str, None, None]: def get_virtual_from(self, value: str) -> str: raise NotImplementedError + class FabricException(Exception): pass diff --git a/thymus/contexts/eos.py b/thymus/contexts/eos.py index 63395bf..a1f79aa 100644 --- a/thymus/contexts/eos.py +++ b/thymus/contexts/eos.py @@ -11,12 +11,6 @@ def nos_type(self) -> str: return 'EOS' def __init__( - self, - name: str, - content: list[str], - *, - encoding: str, - settings: dict[str, str | int], - logger: Logger + self, name: str, content: list[str], *, encoding: str, settings: dict[str, str | int], logger: Logger ) -> None: super().__init__(name, content, encoding=encoding, settings=settings, logger=logger) diff --git a/thymus/contexts/ios.py b/thymus/contexts/ios.py index 807d1e9..1c23010 100644 --- a/thymus/contexts/ios.py +++ b/thymus/contexts/ios.py @@ -193,13 +193,7 @@ def promisc(self, value: str | int | bool) -> None: raise TypeError(f'Incorrect type for promisc: {type(value)}.') def __init__( - self, - name: str, - content: list[str], - *, - encoding: str, - settings: dict[str, str | int], - logger: Logger + self, name: str, content: list[str], *, encoding: str, settings: dict[str, str | int], logger: Logger ) -> None: self.__is_heuristics = False self.__is_base_heuristics = True @@ -212,7 +206,7 @@ def __init__( is_heuristics=self.__is_heuristics, is_base_heuristics=self.__is_base_heuristics, is_crop=self.__is_crop, - is_promisc=self.__is_promisc + is_promisc=self.__is_promisc, ) if not self.__tree: raise Exception(f'{self.nos_type}. Impossible to build a tree.') @@ -236,7 +230,7 @@ def __rebuild_tree(self) -> None: is_heuristics=self.__is_heuristics, is_base_heuristics=self.__is_base_heuristics, is_crop=self.__is_crop, - is_promisc=self.__is_promisc + is_promisc=self.__is_promisc, ) self.__cursor = self.__tree self.logger.debug(f'The tree was rebuilt. {self.nos_type}.') @@ -245,10 +239,10 @@ def __get_node_content(self, node: Root | Node) -> Generator[str, None, None]: return lazy_provide_config(self.content, node, alignment=self.spaces, is_started=True) def __prepand_nop(self, data: Iterable[str]) -> Generator[str | Exception, None, None]: - ''' + """ This method simply adds a blank line to a head of the stream. If the stream is not lazy, it also converts it. The blank line is then eaten by __process_fabric method or __mod methods. Final stream does not contain it. - ''' + """ yield '\n' yield from data @@ -301,10 +295,10 @@ def apply_settings(self, settings: dict[str, str | int]) -> None: super().apply_settings(settings) def update_virtual_cursor(self, value: str) -> Generator[str, None, None]: - ''' + """ This method receives a value from user's input symbol by symbol and tries to guess the next possible path(s) for this input. - ''' + """ if not value: return value = value.lower() @@ -349,16 +343,16 @@ def update_virtual_cursor(self, value: str) -> Generator[str, None, None]: if self.__is_heuristics and command not in self.keywords['go']: yield from chain( self.__update_virtual_cursor(copy(data), is_heuristics=False), - self.__update_virtual_cursor(copy(data), is_heuristics=True) + self.__update_virtual_cursor(copy(data), is_heuristics=True), ) else: yield from self.__update_virtual_cursor(data, is_heuristics=False) def get_virtual_from(self, value: str) -> str: - ''' + """ This method receives a value from user's input after a Tab's strike and returns a word that should be replaced in the input. - ''' + """ if not value: return '' parts: list[str] = value.split() @@ -416,7 +410,7 @@ def mod_wildcard( self, data: Iterator[str] | Generator[str | Exception, None, None], args: list[str], - jump_node: Optional[Node] = None + jump_node: Optional[Node] = None, ) -> Generator[str | Exception, None, None]: if not data or len(args) != 1: yield FabricException('Incorrect arguments for "wildcard".') @@ -441,11 +435,7 @@ def mod_wildcard( except StopIteration: yield FabricException() - def mod_diff( - self, - args: list[str], - jump_node: Optional[Node] = None - ) -> Generator[str | Exception, None, None]: + def mod_diff(self, args: list[str], jump_node: Optional[Node] = None) -> Generator[str | Exception, None, None]: if len(args) != 1: yield FabricException('There must be one argument for "diff".') if not self.name: @@ -454,7 +444,7 @@ def mod_diff( yield FabricException('No other contexts.') context_name = args[0] if self.name == context_name: - yield FabricException('You can\'t compare the same context.') + yield FabricException("You can't compare the same context.") remote_context: Optional[Context] = None for elem in self.__store: if elem.name == context_name and type(elem) is type(self): @@ -481,22 +471,14 @@ def mod_diff( yield '\n' yield from Differ().compare( list(lazy_provide_config(self.content, target, self.spaces)), - list(lazy_provide_config(remote_context.content, peer, remote_context.spaces)) + list(lazy_provide_config(remote_context.content, peer, remote_context.spaces)), ) - def mod_contains( - self, - args: list[str], - jump_node: Optional[Node] = [] - ) -> Generator[str | Exception, None, None]: - + def mod_contains(self, args: list[str], jump_node: Optional[Node] = []) -> Generator[str | Exception, None, None]: def replace_path(source: str, head: str) -> str: return source.replace(head, '').replace(self.delimiter, ' ').strip() - def lookup_child( - node: Node, - path: str = '' - ) -> Generator[str, None, None]: + def lookup_child(node: Node, path: str = '') -> Generator[str, None, None]: for child in node.children: yield from lookup_child(child, path) if not node.is_accessible: @@ -519,18 +501,14 @@ def lookup_child( yield from lookup_child(node, node.path) def __process_fabric( - self, - data: Iterable[str], - mods: list[list[str]], - *, - jump_node: Optional[Node] = None + self, data: Iterable[str], mods: list[list[str]], *, jump_node: Optional[Node] = None ) -> Response: - def __check_leading_mod(name: str, position: int, args_count: int, args_limit: int = 0) -> None: if position: raise FabricException(f'Incorrect position of "{name}".') if args_count != args_limit: raise FabricException(f'Incorrect number of arguments for "{name}". Must be {args_limit}.') + recol_data = self.__prepand_nop(data) try: for number, elem in enumerate(mods): @@ -633,7 +611,7 @@ def command_up(self, args: deque[str], mods: list[list[str]]) -> Response: arg = args.popleft() if arg in self.keywords['show']: if self.__cursor.name == 'root': - return AlertResponse.error('You can\'t do a negative lookahead from the top.') + return AlertResponse.error("You can't do a negative lookahead from the top.") temp = self.__cursor self.command_up(deque(), []) result = self.command_show(args, mods) diff --git a/thymus/contexts/junos.py b/thymus/contexts/junos.py index ffe6322..3ece7b0 100644 --- a/thymus/contexts/junos.py +++ b/thymus/contexts/junos.py @@ -68,13 +68,7 @@ def nos_type(self) -> str: return 'JUNOS' def __init__( - self, - name: str, - content: list[str], - *, - encoding: str, - settings: dict[str, str | int], - logger: Logger + self, name: str, content: list[str], *, encoding: str, settings: dict[str, str | int], logger: Logger ) -> None: super().__init__(name, content, encoding=encoding, settings=settings, logger=logger) self.__tree: Root = construct_tree(self.content, self.delimiter) @@ -144,10 +138,10 @@ def get_heads(node: Root | Node, comp: str) -> Generator[str, None, None]: yield from get_heads(self.__virtual_cursor, head) def __prepand_nop(self, data: Iterable[str]) -> Generator[str | Exception, None, None]: - ''' + """ This method simply adds a blank line to a head of the stream. If the stream is not lazy, it also converts it. The blank line is then eaten by __process_fabric method or __mod methods. Final stream does not contain it. - ''' + """ yield '\n' yield from data @@ -223,9 +217,7 @@ def get_virtual_from(self, value: str) -> str: return new_value def mod_wildcard( - self, - data: Iterator[str] | Generator[str | Exception, None, None], - args: list[str] + self, data: Iterator[str] | Generator[str | Exception, None, None], args: list[str] ) -> Generator[str | Exception, None, None]: # passthrough modificator if not data or len(args) != 1: @@ -245,11 +237,7 @@ def mod_wildcard( except StopIteration: yield FabricException() - def mod_diff( - self, - args: list[str], - jump_node: Optional[Node] = None - ) -> Generator[str | Exception, None, None]: + def mod_diff(self, args: list[str], jump_node: Optional[Node] = None) -> Generator[str | Exception, None, None]: if len(args) != 1: yield FabricException('There must be one argument for "diff".') if not self.name: @@ -258,7 +246,7 @@ def mod_diff( yield FabricException('No other contexts.') context_name = args[0] if self.name == context_name: - yield FabricException('You can\'t compare the same context.') + yield FabricException("You can't compare the same context.") remote_context: Optional[JunOSContext] = None for elem in self.__store: if elem.name == context_name: @@ -308,12 +296,7 @@ def mod_sections(self, jump_node: Optional[Node] = []) -> Generator[str | Except yield '\n' yield from map(lambda x: x['name'], node['children']) - def mod_contains( - self, - args: list[str], - jump_node: Optional[Node] = [] - ) -> Generator[str | Exception, None, None]: - + def mod_contains(self, args: list[str], jump_node: Optional[Node] = []) -> Generator[str | Exception, None, None]: def replace_path(source: str, path: str) -> str: return source.replace(path, '').replace(self.delimiter, ' ').strip() @@ -338,14 +321,8 @@ def lookup_child(node: Root | Node, path: str) -> Generator[str, None, None]: yield from lookup_child(node, node['path'] if 'path' in node else '') def __process_fabric( - self, - data: Iterable[str], - mods: list[list[str]], - *, - jump_node: Optional[Node] = None, - banned: list[str] = [] + self, data: Iterable[str], mods: list[list[str]], *, jump_node: Optional[Node] = None, banned: list[str] = [] ) -> Response: - def __check_leading_mod(name: str, position: int, args_count: int, args_limit: int = 0) -> None: if position: raise FabricException(f'Incorrect position of "{name}".') @@ -470,7 +447,7 @@ def command_up(self, args: deque[str], mods: list[list[str]]) -> Response: arg = args.popleft() if arg in self.keywords['show']: if self.__cursor['name'] == 'root': - return AlertResponse.error('You can\'t do a negative lookahead from the top.') + return AlertResponse.error("You can't do a negative lookahead from the top.") temp = self.__cursor self.__cursor = self.__cursor['parent'] result = self.command_show(args, mods) diff --git a/thymus/lexers/__init__.py b/thymus/lexers/__init__.py index f6d09b3..c31fb9b 100644 --- a/thymus/lexers/__init__.py +++ b/thymus/lexers/__init__.py @@ -8,11 +8,6 @@ ) -from .common import ( - IPV4_REGEXP, - IPV6_REGEXP, - CommonLexer, - SyslogLexer -) +from .common import IPV4_REGEXP, IPV6_REGEXP, CommonLexer, SyslogLexer from .junos import JunosLexer from .ios import IOSLexer diff --git a/thymus/lexers/common/common.py b/thymus/lexers/common/common.py index 42c2a90..d73856d 100644 --- a/thymus/lexers/common/common.py +++ b/thymus/lexers/common/common.py @@ -12,11 +12,7 @@ ) -def wr( - reg_exp: str, - *args: tuple[_TokenType, ...], - **kwargs: Any -) -> tuple[Any, ...]: +def wr(reg_exp: str, *args: tuple[_TokenType, ...], **kwargs: Any) -> tuple[Any, ...]: if 'stage' in kwargs and kwargs['stage']: if len(args) > 1: return reg_exp, bygroups(*args), kwargs['stage'] @@ -33,98 +29,38 @@ class SyslogLexer(RegexLexer): tokens = { 'root': [ # DATE - wr( - r'^\d{4}(?:-\d{2}){2}\s+', - Whitespace, - stage='#push' - ), + wr(r'^\d{4}(?:-\d{2}){2}\s+', Whitespace, stage='#push'), # TIME - wr( - r'\d{2}(?::\d{2}){2},\d+\s+', - Whitespace, - stage='#push' - ), + wr(r'\d{2}(?::\d{2}){2},\d+\s+', Whitespace, stage='#push'), # NAME - wr( - r'[a-z][a-z0-9-_\.]+\s+', - Keyword, - stage='#push' - ), + wr(r'[a-z][a-z0-9-_\.]+\s+', Keyword, stage='#push'), # DEBUG - wr( - r'DEBUG\s', - Generic.DEBUG, - stage='#push' - ), + wr(r'DEBUG\s', Generic.DEBUG, stage='#push'), # INFO - wr( - r'INFO\s', - Generic.INFO, - stage='#push' - ), + wr(r'INFO\s', Generic.INFO, stage='#push'), # WARNING - wr( - r'WARNING\s', - Generic.WARNING, - stage='#push' - ), + wr(r'WARNING\s', Generic.WARNING, stage='#push'), # ERROR - wr( - r'ERROR\s', - Generic.ERROR, - stage='#push' - ), + wr(r'ERROR\s', Generic.ERROR, stage='#push'), # CRITICAL - wr( - r'CRITICAL\s', - Generic.CRITICAL, - stage='#push' - ), + wr(r'CRITICAL\s', Generic.CRITICAL, stage='#push'), # THE REST - wr( - r'[^\n]+\n', - Comment - ) + wr(r'[^\n]+\n', Comment), ] } + class CommonLexer(RegexLexer): flags = IGNORECASE | MULTILINE tokens = { 'root': [ # DIFF/COMPARE + - ( - r'(\s*)(\+)(\s)(.+\n)', - bygroups( - Whitespace, - Generic.Inserted, - Whitespace, - Generic.Inserted - ) - ), + (r'(\s*)(\+)(\s)(.+\n)', bygroups(Whitespace, Generic.Inserted, Whitespace, Generic.Inserted)), # DIFF/COMPARE - - ( - r'(\s*)(-)(\s)(.+\n)', - bygroups( - Whitespace, - Generic.Deleted, - Whitespace, - Generic.Deleted - ) - ), + (r'(\s*)(-)(\s)(.+\n)', bygroups(Whitespace, Generic.Deleted, Whitespace, Generic.Deleted)), # DIFF/COMPARE ? - ( - r'(\s*)(\?)(.+\n)', - bygroups( - Whitespace, - Comment, - Comment - ) - ), + (r'(\s*)(\?)(.+\n)', bygroups(Whitespace, Comment, Comment)), # THE REST - ( - r'.+', - Keyword - ), + (r'.+', Keyword), ], } diff --git a/thymus/lexers/ios/__init__.py b/thymus/lexers/ios/__init__.py index 8b86388..326861b 100644 --- a/thymus/lexers/ios/__init__.py +++ b/thymus/lexers/ios/__init__.py @@ -1,5 +1,3 @@ -__all__ = ( - 'IOSLexer', -) +__all__ = ('IOSLexer',) from .ios import IOSLexer diff --git a/thymus/lexers/ios/ios.py b/thymus/lexers/ios/ios.py index b9d2ab7..1c8bc97 100644 --- a/thymus/lexers/ios/ios.py +++ b/thymus/lexers/ios/ios.py @@ -19,79 +19,23 @@ class IOSLexer(RegexLexer): tokens = { 'root': [ # DIFF/COMPARE + - wr( - r'(\s*)(\+)(\s)(.+)(\n)', - Whitespace, - Generic.Inserted, - Whitespace, - Generic.Inserted, - Whitespace - ), + wr(r'(\s*)(\+)(\s)(.+)(\n)', Whitespace, Generic.Inserted, Whitespace, Generic.Inserted, Whitespace), # DIFF/COMPARE - - wr( - r'(\s*)(-)(\s)(.+)(\n)', - Whitespace, - Generic.Deleted, - Whitespace, - Generic.Deleted, - Whitespace - ), + wr(r'(\s*)(-)(\s)(.+)(\n)', Whitespace, Generic.Deleted, Whitespace, Generic.Deleted, Whitespace), # DIFF/COMPARE ? - wr( - r'(\s*)(\?)(.+)(\n)', - Whitespace, - Comment, - Comment, - Whitespace - ), + wr(r'(\s*)(\?)(.+)(\n)', Whitespace, Comment, Comment, Whitespace), # COMMENT WITH "!" - wr( - r'(\s*)(\!(?:[^\n]+)?)(\n)', - Whitespace, - Comment, - Whitespace - ), + wr(r'(\s*)(\!(?:[^\n]+)?)(\n)', Whitespace, Comment, Whitespace), # COMMENT WITH "\"" - wr( - r'(\s*)("(?:[^\n]+))(\n)', - Whitespace, - Text, - Whitespace - ), + wr(r'(\s*)("(?:[^\n]+))(\n)', Whitespace, Text, Whitespace), # SPECIAL: VERSION - wr( - r'(version)(\s)([^\n]+)(\n)', - Keyword, - Whitespace, - Number, - Whitespace - ), + wr(r'(version)(\s)([^\n]+)(\n)', Keyword, Whitespace, Number, Whitespace), # SPECIAL: HOSTNAME - wr( - r'(hostname)(\s)([^\n]+)(\n)', - Keyword, - Whitespace, - Text, - Whitespace - ), + wr(r'(hostname)(\s)([^\n]+)(\n)', Keyword, Whitespace, Text, Whitespace), # SPECIAL: DESCRIPTION - wr( - r'(\s*)(description)(\s)([^\n]+)(\n)', - Whitespace, - Keyword, - Whitespace, - Text, - Whitespace - ), + wr(r'(\s*)(description)(\s)([^\n]+)(\n)', Whitespace, Keyword, Whitespace, Text, Whitespace), # SPECIAL: INTERFACE - wr( - r'(\s*)(interface)(\s)([^\n\s]+)(\n)', - Whitespace, - Keyword, - Whitespace, - Keyword.Type, - Whitespace - ), + wr(r'(\s*)(interface)(\s)([^\n\s]+)(\n)', Whitespace, Keyword, Whitespace, Keyword.Type, Whitespace), # SPECIAL: SHUTDOWN wr( r'(\s*)(shutdown)(\n)', @@ -99,48 +43,17 @@ class IOSLexer(RegexLexer): Name.Constant, ), # SPECIAL: NO INSTRUCTION - wr( - r'(\s*)(no)(?=\s)', - Whitespace, - Name.Constant, - stage='stager' - ), + wr(r'(\s*)(no)(?=\s)', Whitespace, Name.Constant, stage='stager'), # SPECIAL: NUMERIC RD/RT (DUE TO UNKNOWN GLITCH) wr( - r'(\s+)(rd)(\s)(\d+)(:)(\d+)(\n)', - Whitespace, - Keyword, - Whitespace, - Number, - Text, - Number, - stage='#push' + r'(\s+)(rd)(\s)(\d+)(:)(\d+)(\n)', Whitespace, Keyword, Whitespace, Number, Text, Number, stage='#push' ), # SPECIAL: VLAN - wr( - r'(\s*)(vlan)(\s)([^\n]+)(\n)', - Whitespace, - Keyword, - Whitespace, - Number, - Whitespace - ), + wr(r'(\s*)(vlan)(\s)([^\n]+)(\n)', Whitespace, Keyword, Whitespace, Number, Whitespace), # SPECIAL: NAME - wr( - r'(\s*)(name)(\s)([^\n]+)(\n)', - Whitespace, - Keyword, - Whitespace, - Text, - Whitespace - ), + wr(r'(\s*)(name)(\s)([^\n]+)(\n)', Whitespace, Keyword, Whitespace, Text, Whitespace), # REGULAR INSTRUCTION - wr( - r'(\s*)([^\n\s]+)', - Whitespace, - Keyword, - stage='stager' - ) + wr(r'(\s*)([^\n\s]+)', Whitespace, Keyword, stage='stager'), ], 'stager': [ # DOT EXCEPTION @@ -150,67 +63,21 @@ class IOSLexer(RegexLexer): Name.Tag, ), # IPV4 RD/RT - wr( - rf'(\s+)({IPV4_REGEXP}:)(\d+)(?=\s|\n)', - Whitespace, - Whitespace, - Number, - stage='#push' - ), + wr(rf'(\s+)({IPV4_REGEXP}:)(\d+)(?=\s|\n)', Whitespace, Whitespace, Number, stage='#push'), # IPV6 RD/RT - wr( - rf'(\s+)({IPV6_REGEXP}:)(\d+)(?=\s|\n)', - Whitespace, - Whitespace, - Number, - stage='#push' - ), + wr(rf'(\s+)({IPV6_REGEXP}:)(\d+)(?=\s|\n)', Whitespace, Whitespace, Number, stage='#push'), # NUMERIC RD/RT - wr( - r'(\s+)(\d+)(:)(\d+)(?=\s|\n)', - Whitespace, - Number, - Text, - Number, - stage='#push' - ), + wr(r'(\s+)(\d+)(:)(\d+)(?=\s|\n)', Whitespace, Number, Text, Number, stage='#push'), # IPV4 PREFIX OR ADDRESS - wr( - rf'(\s+)({IPV4_REGEXP}(?:\/\d{1,2})?)(?=\s|\n)', - Whitespace, - Whitespace, - stage='#push' - ), + wr(rf'(\s+)({IPV4_REGEXP}(?:\/\d{1,2})?)(?=\s|\n)', Whitespace, Whitespace, stage='#push'), # IPV6 PREFIX OR ADDRESS - wr( - rf'(\s+)({IPV6_REGEXP}(?:\/\d{1,2})?)(?=\s|\n)', - Whitespace, - Whitespace, - stage='#push' - ), + wr(rf'(\s+)({IPV6_REGEXP}(?:\/\d{1,2})?)(?=\s|\n)', Whitespace, Whitespace, stage='#push'), # MAC ADDRESS IN A PECULIAR NOTATION - wr( - r'(\s+)((?:[a-f0-9]{4}\.){2}[a-f0-9]{4})', - Whitespace, - Whitespace, - stage='#push' - ), + wr(r'(\s+)((?:[a-f0-9]{4}\.){2}[a-f0-9]{4})', Whitespace, Whitespace, stage='#push'), # VLAN - wr( - r'(\s+)(vlan(?:\sadd)?)(\s)([0-9\s,\-]+\d)', - Whitespace, - Keyword, - Whitespace, - Number, - stage='#push' - ), + wr(r'(\s+)(vlan(?:\sadd)?)(\s)([0-9\s,\-]+\d)', Whitespace, Keyword, Whitespace, Number, stage='#push'), # SINGLE RANGE - wr( - r'(\s+)(\d+\-\d+)(?=\s|\n)', - Whitespace, - Number, - stage='#push' - ), + wr(r'(\s+)(\d+\-\d+)(?=\s|\n)', Whitespace, Number, stage='#push'), # LINKS wr( r'(\s+)([a-z0-9-_/]+\.(?:[a-z0-9-_/]+\.)*[a-z0-9-]+)(?=\s|\n)', @@ -219,61 +86,20 @@ class IOSLexer(RegexLexer): stage='#push', ), # HASH - wr( - r'(\s+)([A-F0-9]+)(?=\s|\n)', - Whitespace, - Number, - stage='#push' - ), + wr(r'(\s+)([A-F0-9]+)(?=\s|\n)', Whitespace, Number, stage='#push'), # SPECIAL: REMARK - wr( - r'(\s+)(remark)(\s+)([^\n]+)(\n)', - Whitespace, - Keyword, - Whitespace, - Comment, - Whitespace - ), + wr(r'(\s+)(remark)(\s+)([^\n]+)(\n)', Whitespace, Keyword, Whitespace, Comment, Whitespace), # SPECIAL: DESCRIPTION - wr( - r'(\s+)(description)(\s+)([^\n]+)(\n)', - Whitespace, - Keyword, - Whitespace, - Text, - Whitespace - ), + wr(r'(\s+)(description)(\s+)([^\n]+)(\n)', Whitespace, Keyword, Whitespace, Text, Whitespace), # REGULAR KEYWORD - wr( - r'(?i)(\s+)([a-z][a-z0-9-]+)(?=\s|\n)', - Whitespace, - Keyword, - stage='#push' - ), + wr(r'(?i)(\s+)([a-z][a-z0-9-]+)(?=\s|\n)', Whitespace, Keyword, stage='#push'), # NUMBER - wr( - r'(\s+)(\d+(?:\.\d+)?)(?=\s|\n)', - Whitespace, - Number, - stage='#push' - ), + wr(r'(\s+)(\d+(?:\.\d+)?)(?=\s|\n)', Whitespace, Number, stage='#push'), # QUOTED LINE - wr( - r'(\s+)("[^"]+")(?=\s|\n)', - Whitespace, - Text, - stage="#push" - ), + wr(r'(\s+)("[^"]+")(?=\s|\n)', Whitespace, Text, stage='#push'), # THE REST - wr( - r'\s+[^\s\n]+', - Keyword, - stage='#push' - ), + wr(r'\s+[^\s\n]+', Keyword, stage='#push'), # EOL - wr( - r'\s*\n', - Comment - ) + wr(r'\s*\n', Comment), ], } diff --git a/thymus/lexers/junos/__init__.py b/thymus/lexers/junos/__init__.py index 59a4f30..1344ea0 100644 --- a/thymus/lexers/junos/__init__.py +++ b/thymus/lexers/junos/__init__.py @@ -1,5 +1,3 @@ -__all__ = ( - 'JunosLexer', -) +__all__ = ('JunosLexer',) from .junos import JunosLexer diff --git a/thymus/lexers/junos/junos.py b/thymus/lexers/junos/junos.py index a62dc5c..abd3353 100644 --- a/thymus/lexers/junos/junos.py +++ b/thymus/lexers/junos/junos.py @@ -22,279 +22,65 @@ class JunosLexer(RegexLexer): tokens = { 'root': [ # STANDALONE COMMENT - ( - r'(\s*)(##?(?:\s+[^\n]+)?)', - bygroups( - Whitespace, - Comment - ) - ), + (r'(\s*)(##?(?:\s+[^\n]+)?)', bygroups(Whitespace, Comment)), # STANDALONE ANNOTATION - ( - r'(\s*)(/\*[^\*]+\*/)(\n)', - bygroups( - Whitespace, - Comment, - Operator.Word - ) - ), + (r'(\s*)(/\*[^\*]+\*/)(\n)', bygroups(Whitespace, Comment, Operator.Word)), # DIFF/COMPARE + - ( - r'(\s*)(\+)(\s)(.+\n)', - bygroups( - Whitespace, - Generic.Inserted, - Whitespace, - Generic.Inserted - ) - ), + (r'(\s*)(\+)(\s)(.+\n)', bygroups(Whitespace, Generic.Inserted, Whitespace, Generic.Inserted)), # DIFF/COMPARE - - ( - r'(\s*)(-)(\s)(.+\n)', - bygroups( - Whitespace, - Generic.Deleted, - Whitespace, - Generic.Deleted - ) - ), + (r'(\s*)(-)(\s)(.+\n)', bygroups(Whitespace, Generic.Deleted, Whitespace, Generic.Deleted)), # INACTIVE - ( - r'(\s*)(inactive: )(?=[^\n;\s])', - bygroups( - Whitespace, - Name.Constant - ), - '#push' - ), + (r'(\s*)(inactive: )(?=[^\n;\s])', bygroups(Whitespace, Name.Constant), '#push'), # PROTECTED - ( - r'(\s*)(protected: )(?=[^\n;\s])', - bygroups( - Whitespace, - Whitespace - ), - '#push' - ), + (r'(\s*)(protected: )(?=[^\n;\s])', bygroups(Whitespace, Whitespace), '#push'), # SPECIAL: DESCRIPTION - ( - r'(\s*)(description)(\s)(.+)(;\n)', - bygroups( - Whitespace, - Keyword, - Whitespace, - Text, - Operator.Word - ) - ), + (r'(\s*)(description)(\s)(.+)(;\n)', bygroups(Whitespace, Keyword, Whitespace, Text, Operator.Word)), # SPECIAL: DISABLE - ( - r'(\s*)(disable)(;\n)', - bygroups( - Whitespace, - Name.Constant, - Operator.Word - ) - ), + (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' - ), + (rf'(\s*)({IPV4_REGEXP})', bygroups(Whitespace, Whitespace), 'stager'), # IPV6 STAGER - ( - rf'(\s*)({IPV6_REGEXP})', - bygroups( - Whitespace, - Whitespace - ), - 'stager' - ), + (rf'(\s*)({IPV6_REGEXP})', bygroups(Whitespace, Whitespace), 'stager'), # "TEXT" - ( - r'(\s*)((?<=\s)".+"(?=;|\s))', - bygroups( - Whitespace, - Text - ), - 'stager' - ), + (r'(\s*)((?<=\s)".+"(?=;|\s))', bygroups(Whitespace, Text), 'stager'), # 'TEXT' - ( - r'(\s*)((?<=\s)\'.+\'(?=;|\s))', - bygroups( - Whitespace, - Text - ), - 'stager' - ), + (r'(\s*)((?<=\s)\'.+\'(?=;|\s))', bygroups(Whitespace, Text), 'stager'), # NUMBER OR BANDWIDTH - ( - r'(\s*)(\d+[mkg]?)(;\n)', - bygroups( - Whitespace, - Number, - Operator.Word - ) - ), + (r'(\s*)(\d+[mkg]?)(;\n)', bygroups(Whitespace, Number, Operator.Word)), # THE REST (REGULAR) - ( - r'(\s*)([a-z0-9-_\./\*,$:]+)', - bygroups( - Whitespace, - Keyword - ), - 'stager' - ), + (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 - ) - ), + (r'(\s*)(\})(\s##\s[^\n]+)?(\n)', bygroups(Whitespace, Operator.Word, Comment, Whitespace)), ], 'stager': [ # ASTERISK SECTIONS (e.g., unit *) - ( - r'(\s)(\*)', - bygroups( - Whitespace, - Keyword - ), - '#push' - ), + (r'(\s)(\*)', bygroups(Whitespace, Keyword), '#push'), # START OF A SQUARE BLOCK - ( - r'(\s)(\[)(\s)', - bygroups( - Whitespace, - Operator.Word, - Whitespace - ), - '#push' - ), + (r'(\s)(\[)(\s)', bygroups(Whitespace, Operator.Word, Whitespace), '#push'), # IPV4 ADDRESS OR PREFIX - ( - rf'(\s)?({IPV4_REGEXP})', - bygroups( - Whitespace, - Whitespace - ), - '#push' - ), + (rf'(\s)?({IPV4_REGEXP})', bygroups(Whitespace, Whitespace), '#push'), # IPV6 ADDRESS OR PREFIX - ( - rf'(\s)?({IPV6_REGEXP})', - bygroups( - Whitespace, - Whitespace - ), - '#push' - ), + (rf'(\s)?({IPV6_REGEXP})', bygroups(Whitespace, Whitespace), '#push'), # NUMBER OF BANDWIDTH - ( - r'(\s)?(\d+[mkg]?(?=;|\s))', - bygroups( - Whitespace, - Number - ), - '#push' - ), + (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' - ), + (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' - ), + (r'(\s*)((?<=\s)".+?"(?=;|\s))', bygroups(Whitespace, Text), '#push'), # "TEXT" IN A SQUARES BLOCK - ( - r'(\s*)("[^"]+")(\s)', - bygroups( - Whitespace, - Text, - Whitespace - ), - '#push' - ), + (r'(\s*)("[^"]+")(\s)', bygroups(Whitespace, Text, Whitespace), '#push'), # 'TEXT' (NOT GREEDY) - ( - r'(\s*)((?<=\s)\'.+?\'(?=;|\s))', - bygroups( - Whitespace, - Text - ), - '#push' - ), + (r'(\s*)((?<=\s)\'.+?\'(?=;|\s))', bygroups(Whitespace, Text), '#push'), # 'TEXT' IN A SQUARES BLOCK - ( - r'(\s*)(\'[^\']+\')(\s)', - bygroups( - Whitespace, - Text, - Whitespace - ), - '#push' - ), + (r'(\s*)(\'[^\']+\')(\s)', bygroups(Whitespace, Text, Whitespace), '#push'), # INLINE ANNOTATIONS - ( - r'(\s)?(/\*[^\*]+\*/)(?:(\s)(\}))?', - bygroups( - Whitespace, - Comment, - Whitespace, - Operator.Word - ), - '#push' - ), + (r'(\s)?(/\*[^\*]+\*/)(?:(\s)(\}))?', bygroups(Whitespace, Comment, Whitespace, Operator.Word), '#push'), # THE REST (REGULAR) - ( - r'(\s)?([a-z0-9-_\./+=\*:^&$,]+)', - bygroups( - Whitespace, - Keyword.Type - ), - '#push' - ), + (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' - ), + (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' - ), + (r'(\s)?(\]?;)(\s##\s[^\n]+)?', bygroups(Whitespace, Operator.Word, Comment), '#pop'), ], } diff --git a/thymus/misc/utils.py b/thymus/misc/utils.py index dbc0dd1..950cfbb 100644 --- a/thymus/misc/utils.py +++ b/thymus/misc/utils.py @@ -12,5 +12,6 @@ def find_common(elems: list[str]) -> str: return result return result + def rreplace(line: str, pattern: str, replacement: str, count: int = 1) -> str: return replacement.join(line.rsplit(pattern.lstrip(), count)) diff --git a/thymus/responses/responses.py b/thymus/responses/responses.py index 68703e6..9c0dfab 100644 --- a/thymus/responses/responses.py +++ b/thymus/responses/responses.py @@ -4,7 +4,7 @@ class Response: - __slots__: tuple[str, ...] = ( + __slots__ = ( '__status', '__value', ) @@ -39,14 +39,18 @@ def error(cls, value: Any) -> Response: def success(cls, value: Any = '') -> Response: return cls('success', value) + class SettingsResponse(Response): ... + class AlertResponse(Response): ... + class ContextResponse(Response): rtype: str = 'data' + class RichResponse(Response): rtype: str = 'rich' diff --git a/thymus/settings/__init__.py b/thymus/settings/__init__.py new file mode 100644 index 0000000..6edec5b --- /dev/null +++ b/thymus/settings/__init__.py @@ -0,0 +1,17 @@ +from .settings import AppSettings +from .platform import ( + Platform, + JunosPlatform, + IOSPlatform, + NXOSPlatform, + EOSPlatform, +) + +__all__ = ( + 'AppSettings', + 'Platform', + 'JunosPlatform', + 'IOSPlatform', + 'NXOSPlatform', + 'EOSPlatform', +) diff --git a/thymus/settings/platform.py b/thymus/settings/platform.py new file mode 100644 index 0000000..60bf4b6 --- /dev/null +++ b/thymus/settings/platform.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import logging + +from abc import ABC, abstractmethod + +from ..contexts import ( + Context, + JunOSContext, + IOSContext, + EOSContext, + NXOSContext, +) + + +class Platform(ABC): + __slots__ = ('_logger',) + settings: dict[str, str | int] = {} + context = Context + current_settings: dict[str, str | int] = {} + full_name = '' + short_name = '' + show_command = '' + netmiko_ssh_class = '' + netmiko_telnet_class = '' + + def __init__(self, logger: logging.Logger) -> None: + self._logger = logger + self.current_settings = self.settings + + @abstractmethod + def validate(self, key: str, value: str | int) -> str | int: + raise NotImplementedError + + +class JunosPlatform(Platform): + settings = { + 'spaces': 2, + } + context: type[Context] = JunOSContext + show_command = 'show configuration | display inheritance no-comments' + full_name = 'Juniper JunOS' + short_name = 'JunOS' + netmiko_ssh_class = 'juniper_junos' + netmiko_telnet_class = 'juniper_junos_telnet' + + def validate(self, key: str, value: str | int) -> str | int: + if key == 'spaces': + value = int(value) + if value <= 0: + raise Exception + else: + self._logger.warning(f'Unknown {self.short_name} attribute: {key}. Ignore.') + return value + + +class IOSPlatform(Platform): + settings: dict[str, str | int] = { + 'spaces': 1, + 'heuristics': 'off', + 'base_heuristics': 'on', + 'crop': 'off', + 'promisc': 'off', + } + context: type[Context] = IOSContext + full_name = 'Cisco IOS' + short_name = 'IOS' + show_command = 'show running-config' + netmiko_ssh_class = 'cisco_ios' + netmiko_telnet_class = 'cisco_ios_telnet' + + def validate(self, key: str, value: str | int) -> str | int: + if key == 'spaces': + value = int(value) + if value <= 0: + raise Exception + elif key in ('heuristics', 'crop', 'promisc', 'base_heuristics'): + if value not in ('0', '1', 'on', 'off', 0, 1): + raise Exception + else: + self._logger.warning(f'Unknown {self.short_name} attribute: {key}. Ignore.') + return value + + +class NXOSPlatform(Platform): + settings: dict[str, str | int] = { + 'spaces': 2, + 'heuristics': 'off', + 'base_heuristics': 'on', + 'crop': 'off', + } + context: type[Context] = NXOSContext + full_name = 'Cisco NX-OS' + short_name = 'NXOS' + show_command = 'show running-config' + netmiko_ssh_class = 'cisco_nxos' + netmiko_telnet_class = 'cisco_ios_telnet' + + def validate(self, key: str, value: str | int) -> str | int: + if key == 'spaces': + value = int(value) + if value <= 0: + raise Exception + elif key in ('heuristics', 'crop', 'base_heuristics'): + if value not in ('0', '1', 'on', 'off', 0, 1): + raise Exception + else: + self._logger.warning(f'Unknown {self.short_name} attribute: {key}. Ignore.') + return value + + +class EOSPlatform(Platform): + settings: dict[str, str | int] = { + 'spaces': 2, + 'heuristics': 'off', + 'base_heuristics': 'on', + 'crop': 'off', + 'promisc': 'off', + } + context: type[Context] = EOSContext + full_name = 'Arista EOS' + short_name = 'EOS' + show_command = 'show running-config' + netmiko_ssh_class = 'arista_eos' + netmiko_telnet_class = 'arista_eos_telnet' + + def validate(self, key: str, value: str | int) -> str | int: + if key == 'spaces': + value = int(value) + if value <= 0: + raise Exception + elif key in ('heuristics', 'crop', 'promisc', 'base_heuristics'): + if value not in ('0', '1', 'on', 'off', 0, 1): + raise Exception + else: + self._logger.warning(f'Unknown {self.short_name} attribute: {key}. Ignore.') + return value diff --git a/thymus/settings/settings.py b/thymus/settings/settings.py new file mode 100644 index 0000000..87e7dc7 --- /dev/null +++ b/thymus/settings/settings.py @@ -0,0 +1,447 @@ +from __future__ import annotations + +import os +import json +import logging + +from typing import Any, cast +from logging.handlers import RotatingFileHandler, BufferingHandler + +from pygments.styles import get_all_styles # type: ignore + +from .. import __version__ as app_ver +from .. import ( + CONFIG_PATH, + CONFIG_NAME, + LOGGING_FILE, + LOGGING_FILE_DIR, + LOGGING_FORMAT, + LOGGING_CONF, + LOGGING_CONF_ENCODING, + LOGGING_FILE_MAX_SIZE_BYTES, + LOGGING_FILE_MAX_INSTANCES, + LOGGING_FILE_ENCODING, + LOGGING_BUF_CAP, + N_VALUE_LIMIT, + SAVES_DIR, + SCREENS_DIR, + WRAPPER_DIR, +) +from ..responses import Response, SettingsResponse +from .platform import ( + Platform, + JunosPlatform, + IOSPlatform, + EOSPlatform, + NXOSPlatform, +) + + +def check_folder(path: str, *, logger: logging.Logger) -> bool: + if os.path.exists(path): + if not os.path.isdir(path): + logger.error(f'The path "{path}" exits, but it is not a folder!') + return False + else: + try: + os.mkdir(path) + logger.debug(f'The "{path}" was created.') + except Exception as error: + logger.error(f'An error has occurred during the "{path}" creation: {error}.') + return False + return True + + +class AppSettings: + __slots__ = ( + '_platforms', + '_logger', + '_is_alert', + ) + settings: dict[str, str | int] = { + 'theme': 'monokai', + 'night_mode': 'off', + 'filename_len': 256, + 'sidebar_limit': 64, + 'sidebar_strict_on_tab': 'on', + 'open_dialog_platform': 'junos', + 'default_folder': os.path.join(WRAPPER_DIR, SAVES_DIR), + } + current_settings: dict[str, str | int] = {} + + @property + def logger(self) -> logging.Logger: + """For external access, e.g., from tui.py.""" + return self._logger + + @property + def platforms(self) -> dict[str, Platform]: + """For external access, e.g. from open_dialog.py.""" + return self._platforms + + def __init__(self) -> None: + self._platforms: dict[str, Platform] = {} + self._is_alert = False + self.init_mem_logging() # must be first + self.load_defaults() + self.init_folders() + if not self._is_alert and self.read_config(): + self.init_file_logging() + self._logger.info(f'Thymus {app_ver} started normally.') + else: + self._logger.info(f'Thymus {app_ver} started with errors. It is a read-only mode.') + + def load_defaults(self) -> None: + for k, v in self.settings.items(): + self.current_settings[k] = v + # REGISTER ALL POSSIBLE PLATFORMS HERE + self._platforms = { + 'junos': JunosPlatform(logger=self._logger), + 'ios': IOSPlatform(logger=self._logger), + 'nxos': NXOSPlatform(logger=self._logger), + 'eos': EOSPlatform(logger=self._logger), + } + + def init_mem_logging(self) -> None: + self._logger = logging.getLogger(__name__) + self._logger.setLevel(logging.INFO) + formatter = logging.Formatter(LOGGING_FORMAT) + buf_handler = BufferingHandler(LOGGING_BUF_CAP) + buf_handler.setFormatter(formatter) + self._logger.addHandler(buf_handler) + self._logger.debug('Memory logging started.') + + def init_folders(self) -> None: + wrapper_path = os.path.expanduser(WRAPPER_DIR) + if not check_folder(wrapper_path, logger=self._logger): + self._is_alert = True + return + settings_path = os.path.join(wrapper_path, CONFIG_PATH) + if not check_folder(settings_path, logger=self._logger): + self._is_alert = True + return + logs_path = os.path.join(wrapper_path, LOGGING_FILE_DIR) + check_folder(logs_path, logger=self._logger) + saves_path = os.path.join(wrapper_path, SAVES_DIR) + check_folder(saves_path, logger=self._logger) + screens_path = os.path.join(wrapper_path, SCREENS_DIR) + check_folder(screens_path, logger=self._logger) + + def init_file_logging(self) -> None: + wrapper_path = os.path.expanduser(WRAPPER_DIR) + log_conf_path = os.path.join(wrapper_path, LOGGING_CONF) + data = {} + default_config = { + 'level': 'INFO', + 'format': LOGGING_FORMAT, + 'filename': os.path.join(wrapper_path, LOGGING_FILE), + 'max_size_bytes': LOGGING_FILE_MAX_SIZE_BYTES, + 'max_files': LOGGING_FILE_MAX_INSTANCES, + 'encoding': LOGGING_FILE_ENCODING, + } + if not os.path.exists(log_conf_path): + # If the logging config file does not exist we try to create if first + try: + with open(log_conf_path, 'w', encoding=LOGGING_CONF_ENCODING) as f: + json.dump(default_config, f, indent=4) + f.flush() + os.fsync(f.fileno()) + except Exception as error: + err_msg = f'Error has occurred during the creating of "{log_conf_path}". ' + err_msg += f'Exception: "{error}".' + self._logger.error(err_msg) + else: + # If it is here, check whether it is dir or not + if not os.path.isdir(log_conf_path): + # Try to open it and fill the data with its settings + f = open(log_conf_path, encoding='utf-8') + data = json.load(f) + if not data: + self._logger.error(f'The file "{log_conf_path}" is empty or cannot be read.') + else: + self._logger.error(f'The path "{log_conf_path}" is a directory.') + try: + # This sections tryes to start file logging + # It uses either the data which contains settings from the config file or the default settings + level_val = data.get('level', default_config['level']) + logging_level = logging.INFO + if level_val == 'ERROR': + logging_level = logging.ERROR + elif level_val == 'WARNING': + logging_level = logging.WARNING + elif level_val == 'DEBUG': + logging_level = logging.DEBUG + self._logger.setLevel(logging_level) # reset the log-level + format_val = data.get('format', default_config['format']) + formatter = logging.Formatter(format_val) + file_handler = RotatingFileHandler( + filename=data.get('filename', default_config['filename']), + maxBytes=int(data.get('max_size_bytes', default_config['max_size_bytes'])), + backupCount=int(data.get('max_files', default_config['max_files'])), + encoding=data.get('encoding', default_config['encoding']), + ) + file_handler.setFormatter(formatter) + self._logger.addHandler(file_handler) + except Exception as error: + err_msg = 'Error has occurred during the file logging start. ' + err_msg += f'Exception: "{error}".' + self._logger.error(err_msg) + else: + # If everything is fine we need to fill the file log with values from the memory log + for handler in self._logger.handlers: + if type(handler) is not BufferingHandler: + continue + for record in handler.buffer: + file_handler.emit(record) + self._logger.debug('File logging started.') + + def read_config(self) -> bool: + wrapper_path = os.path.expanduser(WRAPPER_DIR) + conf_file_path = os.path.join(wrapper_path, CONFIG_PATH, CONFIG_NAME) + if os.path.exists(conf_file_path): + # If the config file path exists + if os.path.isfile(conf_file_path): + # Make sure that it is a file and try to open + try: + f = open(conf_file_path, encoding='utf-8') + data = json.load(f) + if not data: + # We do not try to delete the file and re-create it properly, leaving the decision for the user + raise Exception(f'The file "{conf_file_path}" is empty or cannot be read.') + # Bootstrap the system and the platforms settings + self.bootstrap_settings(data) + for platform in self._platforms: + if platform_data := data.get(platform): + self.bootstrap_settings(platform_data, platform=platform) + else: + self._logger.warning(f'No data for {platform.upper()}. Default platform data selected.') + except Exception as error: + err_msg = f'Error has occurred during reading of "{conf_file_path}". ' + err_msg += f'Exception: "{error}".' + self._logger.error(err_msg) + self._is_alert = True + return False + else: + self._logger.error(f'The path "{conf_file_path}" is a directory.') + self._is_alert = True + return False + else: + # The system config file does not exist, so we need to create it + # It is already filled with the default data thanks to the load_defaults() call + result = self.save_config() + self._is_alert = not result + return result + self._logger.debug(f'The config file "{conf_file_path}" was read.') + return True + + def save_config(self) -> bool: + wrapper_path = os.path.expanduser(WRAPPER_DIR) + conf_file_path = os.path.join(wrapper_path, CONFIG_PATH, CONFIG_NAME) + data: dict[str, str | int | dict[str, str | int]] = {} + for k, v in self.current_settings.items(): + data[k] = v + for platform, platform_obj in self._platforms.items(): + data.update({platform: platform_obj.current_settings}) + try: + with open(conf_file_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=4) + f.flush() + os.fsync(f.fileno()) + except Exception as error: + err_msg = f'Error has occurred during saving of "{conf_file_path}". ' + err_msg += f'Exception: "{error}".' + self._logger.error(err_msg) + return False + self._logger.debug(f'System settings were saved to: {conf_file_path}.') + return True + + def validate(self, key: str, value: str | int) -> str | int: + if key == 'theme': + if value not in get_all_styles(): + raise Exception + elif key == 'filename_len': + value = int(value) + if value <= 0 or value > N_VALUE_LIMIT: + raise Exception + elif key == 'sidebar_limit': + value = int(value) + if value <= 0 or value > N_VALUE_LIMIT: + raise Exception + elif key == 'open_dialog_platform': + if value not in self._platforms: + raise Exception + elif key in ('sidebar_strict_on_tab', 'night_mode'): + if value not in ('0', '1', 'on', 'off', 0, 1): + raise Exception + elif key == 'default_folder': + path = os.path.expanduser(str(value)) + if not os.path.exists(path) or not os.path.isdir(path): + raise Exception + else: + self._logger.warning(f'Unknown global attribute: {key}. Ignored.') + return value + + def bootstrap_settings(self, data: Any, *, platform='') -> None: + if type(data) is not dict: + return + obj = self.settings + if platform: + if platform not in self._platforms: + return + obj = self._platforms[platform].settings + for key in obj: + try: + value = data.get(key, '') + if not value: + err_msg = f'Validation error, an empty value for the "{key}" received.' + err_msg += ' Default value selected.' + self._logger.error(err_msg) + continue + if type(value) is not str and type(value) is not int: + err_msg = f'Validation error, value type mismatch: {key}->{type(value)}.' + err_msg += ' Default value selected.' + self._logger.error(err_msg) + continue + value = cast('str | int', value) + if not platform: + value = self.validate(key, value) + self.current_settings[key] = value + else: + value = self._platforms[platform].validate(key, value) + self._platforms[platform].current_settings[key] = value + except Exception: + err_msg = f'Validation key "{key}" failed. Default value selected.' + self._logger.error(err_msg) + + def process_command(self, command: str) -> Response: + if not command.startswith('global '): + return SettingsResponse.error('Unknown global command.') + parts = command.split() + if len(parts) < 3: + return SettingsResponse.error('Incomplete global command.') + subcommand = parts[1] + if subcommand == 'show': + if len(parts) > 4: + return SettingsResponse.error('Too many arguments for "global show" command.') + arg = parts[2] + if arg == 'themes': + if len(parts) == 4: + return SettingsResponse.error(f'Too many arguments for "global show {arg}" command.') + themes_result = [] + themes_result.append('* -- current theme') + for theme in get_all_styles(): + themes_result.append(f'{theme}*' if theme == self.current_settings['theme'] else theme) + return SettingsResponse.success(themes_result) + elif arg == 'open_dialog_platform': + if len(parts) == 4: + return SettingsResponse.error(f'Too many arguments for "global show {arg}" command.') + platf_result = self.current_settings[arg] + return SettingsResponse.success(str(platf_result)) + elif arg in ('filename_len', 'sidebar_limit'): + if len(parts) == 4: + return SettingsResponse.error(f'Too many arguments for "global show {arg}" command.') + num_result = self.current_settings[arg] + return SettingsResponse.success(str(num_result)) + elif arg in ('sidebar_strict_on_tab', 'night_mode'): + if len(parts) == 4: + return SettingsResponse.error(f'Too many arguments for "global show {arg}" command.') + bool_result = self.current_settings[arg] + return SettingsResponse.success(str(bool_result)) + elif arg == 'default_folder': + if len(parts) == 4: + return SettingsResponse.error(f'Too many arguments for "global show {arg}" command.') + df_result = self.current_settings[arg] + return SettingsResponse.success(str(df_result)) + elif arg in self._platforms: + if len(parts) == 4: + subarg = parts[3] + if subarg in ('default', 'defaults'): + return SettingsResponse.success( + iter(f'{k}: {v}' for k, v in self._platforms[arg].settings.items()) + ) + else: + return SettingsResponse.error( + f'Unknown sub-argument for "global show {arg}" command: {subarg}.' + ) + else: + if self._is_alert: + return SettingsResponse.error('These settings are default. App started abnormally.') + if not self._platforms[arg]: + return SettingsResponse.success( + iter(f'{k}: {v}' for k, v in self._platforms[arg].settings.items()) + ) + return SettingsResponse.success( + iter(f'{k}: {v}' for k, v in self._platforms[arg].settings.items()) + ) + else: + return SettingsResponse.error(f'Unknown argument for "global show" command: {arg}.') + elif subcommand == 'set': + if self._is_alert: + return SettingsResponse.error('The settings system is in read-only mode. App started abnormally.') + if len(parts) < 4: + return SettingsResponse.error('Incomplete "global set" command.') + arg = parts[2] + if arg == 'theme': + if len(parts) > 4: + return SettingsResponse.error('Too many arguments for "global set" command.') + value = parts[3] + if value not in get_all_styles(): + return SettingsResponse.error(f'Unsupported theme: {value}.') + self.current_settings[arg] = value + self.save_config() + return SettingsResponse.success(f'The "{arg}" was changed to: {value}.') + elif arg == 'open_dialog_platform': + if len(parts) > 4: + return SettingsResponse.error('Too many arguments for "global set" command.') + value = parts[3] + if value not in self._platforms: + return SettingsResponse.error(f'Unsupported platform: {value}.') + self.current_settings[arg] = value + self.save_config() + return SettingsResponse.success(f'The "{arg}" was changed to: {value}.') + elif arg in ('filename_len', 'sidebar_limit'): + if len(parts) > 4: + return SettingsResponse.error(f'Too many arguments for "global set {arg}" command.') + value = parts[3] + if not value.isdigit() or int(value) <= 0 or int(value) > N_VALUE_LIMIT: + return SettingsResponse.error(f'Value must be in (0; {N_VALUE_LIMIT}].') + self.current_settings[arg] = int(value) + self.save_config() + return SettingsResponse.success(f'The "{arg}" was changed to: {value}.') + elif arg in ('sidebar_strict_on_tab', 'night_mode'): + if len(parts) > 4: + return SettingsResponse.error(f'Too many arguments for "global set {arg}" command.') + value = parts[3] + if value not in ('0', '1', 'on', 'off', 0, 1): + return SettingsResponse.error('Value must be in (0, 1, on, off).') + self.current_settings[arg] = value + self.save_config() + return SettingsResponse.success(f'The "{arg}" was changed to: {value}.') + elif arg == 'default_folder': + if len(parts) > 4: + return SettingsResponse.error('Too many arguments for "global set" command.') + value = parts[3] + path = os.path.expanduser(str(value)) + if not os.path.exists(path) or not os.path.isdir(path): + return SettingsResponse.error('Default directory does not exist.') + self.current_settings[arg] = value + self.save_config() + return SettingsResponse.success(f'The "{arg}" was changed to: {value}.') + elif arg in self._platforms: + if len(parts) != 5: + return SettingsResponse.error(f'Incorrent number of arguments for "global set {arg}" command.') + subarg = parts[3] + if subarg not in self._platforms[arg].current_settings: + return SettingsResponse.error(f'Unknown sub-argument for "global set {arg}" command: {subarg}.') + value = parts[4] + try: + value = str(self._platforms[arg].validate(subarg, value)) + self._platforms[arg].current_settings[subarg] + except Exception: + return SettingsResponse.error(f'Cannot set the value for "{subarg}".') + self.save_config() + return SettingsResponse.success(f'Attribute "{subarg}" was changed to: {value}.') + else: + return SettingsResponse.error(f'Unknown argument for "global set" command: {arg}.') + else: + return SettingsResponse.error(f'Unknown global command: {subcommand}.') diff --git a/thymus/templates/context_help.json b/thymus/templates/context_help.json new file mode 100644 index 0000000..fe35c86 --- /dev/null +++ b/thymus/templates/context_help.json @@ -0,0 +1,25 @@ +{ + "header": "[bold yellow]Welcome to the {NOS} context.[/]", + "footer": "\nSome commands may be used with arguments. Please, see the [link=https://github.com/blademd/thymus/wiki]documentation[/link].\nEnter any command in the input field below.", + "modificators_header": "\nUse any of the next sub-commands for the [bold yellow]show[/] command after a pipe symbol:", + "singletones": { + "show": " To show a configuration of the current path use: [bold yellow]{CMDS}[/].", + "go": " To switch the current path use: [bold yellow]{CMDS}[/].", + "top": " To switch the current path to the top use: [bold yellow]{CMDS}[/].", + "up": " To step back one or more sections use: [bold yellow]{CMDS}[/].", + "help": " To show these hints use: [bold yellow]{CMDS}[/].", + "version": " To show a version of this configuration file use: [bold yellow]{CMDS}[/].", + "set": " To configure the current context settings use: [bold yellow]{CMDS}[/].", + "global": " To configure the application settings use: [bold yellow]{CMDS}[/] and [bold yellow]show[/] or [bold yellow]set[/]." + }, + "modificators": { + "filter": " To filter a single line from the output use: [bold yellow]{CMDS}[/].", + "wildcard": " To filter a single section from the output use: [bold yellow]{CMDS}[/].", + "stubs": " To show all finite instructions (stubs) for the current path use: [bold yellow]{CMDS}[/].", + "sections": " To list all available nested sections use: [bold yellow]{CMDS}[/].", + "save": " To save a content of the current path to a file use: [bold yellow]{CMDS}[/].", + "count": " To count lines of the output use: [bold yellow]{CMDS}[/].", + "diff": " To compare two contexts use: [bold yellow]{CMDS}[/].", + "contains": " To search a pattern in the configuration use: [bold yellow]{CMDS}[/]." + } +} diff --git a/thymus/tui/__init__.py b/thymus/tui/__init__.py index 78a515f..bbbbb15 100644 --- a/thymus/tui/__init__.py +++ b/thymus/tui/__init__.py @@ -1,6 +1,4 @@ -__all__ = ( - 'OpenDialog', -) +__all__ = ('OpenDialog',) from .open_dialog import ( OpenDialog, diff --git a/thymus/tui/modals/contexts_modal.py b/thymus/tui/modals/contexts_modal.py index 6f8449d..b01cc67 100644 --- a/thymus/tui/modals/contexts_modal.py +++ b/thymus/tui/modals/contexts_modal.py @@ -23,9 +23,7 @@ class ContextListScreen(ModalScreen): def compose(self) -> ComposeResult: yield OptionList( - Option('Please, select a context to work with (Esc to quit):', id='cm-title'), - Separator(), - id='cm-options' + Option('Please, select a context to work with (Esc to quit):', id='cm-title'), Separator(), id='cm-options' ) def on_show(self) -> None: diff --git a/thymus/tui/modals/error_modal.py b/thymus/tui/modals/error_modal.py index 53f50de..ab9558b 100644 --- a/thymus/tui/modals/error_modal.py +++ b/thymus/tui/modals/error_modal.py @@ -18,11 +18,7 @@ class ErrorScreen(ModalScreen): ] def __init__( - self, - err_msg: str, - name: Optional[str] = None, - id: Optional[str] = None, - classes: Optional[str] = None + self, err_msg: str, name: Optional[str] = None, id: Optional[str] = None, classes: Optional[str] = None ) -> None: super().__init__(name, id, classes) self.err_msg = err_msg diff --git a/thymus/tui/modals/logs_modal.py b/thymus/tui/modals/logs_modal.py index aef41e6..d999187 100644 --- a/thymus/tui/modals/logs_modal.py +++ b/thymus/tui/modals/logs_modal.py @@ -64,8 +64,6 @@ def on_show(self) -> None: theme = ANSISyntaxTheme(SYSLOG_DARK_STYLES) if not self.app.dark: theme = ANSISyntaxTheme(SYSLOG_LIGHT_STYLES) - if not self.app.logger: - return for handler in self.app.logger.handlers: if type(handler) is not BufferingHandler: continue diff --git a/thymus/tui/modals/quit_modal.py b/thymus/tui/modals/quit_modal.py index 01e387c..4e3ef8b 100644 --- a/thymus/tui/modals/quit_modal.py +++ b/thymus/tui/modals/quit_modal.py @@ -16,6 +16,8 @@ class QuitApp(ModalScreen): + app: TThymus + def compose(self) -> ComposeResult: with Grid(id='qs-dialog'): yield Label('Are you sure you want to quit [b]Thymus[/b]?', id='qs-question') @@ -34,6 +36,7 @@ def quit_app(self) -> None: def cancel(self) -> None: self.app.pop_screen() + class QuitScreen(ModalScreen): app: TThymus @@ -42,7 +45,7 @@ def __init__( screen: WorkingScreen, name: Optional[str] = None, id: Optional[str] = None, - classes: Optional[str] = None + classes: Optional[str] = None, ) -> None: super().__init__(name, id, classes) self.screen_to_quit: WorkingScreen = screen @@ -65,7 +68,8 @@ def quit_screen(self) -> None: if self.screen_to_quit.name in self.app.working_screens: self.app.working_screens.remove(self.screen_to_quit.name) self.app.uninstall_screen(self.screen_to_quit) - self.screen_to_quit.context.free() + if self.screen_to_quit.context: + self.screen_to_quit.context.free() @on(Button.Pressed, '#qs-cancel') def cancel(self) -> None: diff --git a/thymus/tui/net_loader.py b/thymus/tui/net_loader.py index 3e47392..85b0aea 100644 --- a/thymus/tui/net_loader.py +++ b/thymus/tui/net_loader.py @@ -2,39 +2,43 @@ from netmiko import ConnectHandler # type: ignore +from ..settings import Platform -P_MAP = { - 'junos': ('juniper_junos', 'juniper_junos_telnet', 'show configuration | display inheritance no-comments'), - 'ios': ('cisco_ios', 'cisco_ios_telnet', 'show running-config'), - 'eos': ('arista_eos', 'arista_eos_telnet', 'show running-config'), - 'nxos': ('cisco_nxos', 'cisco_ios_telnet', 'show running-config'), -} class NetLoader: - __slots__ = ( - '__data', - ) + __slots__ = ('__data',) @property def data(self) -> list[str]: return self.__data - def __init__(self, host: str, port: int, username: str, password: str, proto: int, platform: str) -> None: - if platform not in P_MAP: - raise ValueError(f'The "{platform}" is not supported.') + def __init__( + self, + *, + host: str, + platform: Platform, + port: int = 22, + username: str = '', + password: str = '', + proto: int = 0, + ) -> None: + """ + `proto` - zero is for SSH, one is for Telnet. + """ if proto not in (0, 1): raise ValueError('This protocol is not supported!') - if not username or not password or not host or not port: - raise ValueError('All the fields must be set!') self.__data: list[str] = [] + device_type = platform.netmiko_ssh_class if not proto else platform.netmiko_telnet_class conn = ConnectHandler( - device_type=P_MAP[platform][proto], + device_type=device_type, host=host, port=port, username=username, password=password, + allow_agent=True, + system_host_keys=True, ) - command = P_MAP[platform][2] + command = platform.show_command result = conn.send_command(command) if not result: raise Exception('Network has returned an empty result.') diff --git a/thymus/tui/open_dialog.py b/thymus/tui/open_dialog.py index 7a4a16d..3c2c62b 100644 --- a/thymus/tui/open_dialog.py +++ b/thymus/tui/open_dialog.py @@ -44,21 +44,28 @@ class OpenDialog(ModalScreen): BINDINGS = [ ('escape', 'app.pop_screen', 'Pop screen'), - ('p', 'focus(\'platform\')', 'Platform'), - ('e', 'focus(\'encoding\')', 'Encoding'), - ('t', 'focus(\'tree\')', 'Tree'), - ('l', 'focus(\'prev_tab\')', ' File tab'), - ('r', 'focus(\'next_tab\')', 'Network tab'), + ('p', "focus('platform')", 'Platform'), + ('e', "focus('encoding')", 'Encoding'), + ('t', "focus('tree')", 'Tree'), + ('l', "focus('prev_tab')", ' File tab'), + ('r', "focus('next_tab')", 'Network tab'), ] current_path: var[Path] = var(Path.cwd()) lock: var[bool] = var(False) + def __get_list_view_value(self, id: str) -> str: + control = self.query_one(id, ListView) + if not control or not control.highlighted_child or not control.highlighted_child.children: + return '' + value = control.highlighted_child.children[0].name + if not value: + return '' + return value + def __open(self, filename: str = '', content: list[str] = []) -> None: screen_name = str(uuid4()) - nos_switch = self.query_one('#od-nos-switch', ListView) - encoding_switch = self.query_one('#od-encoding-switch', ListView) - selected_nos = nos_switch.highlighted_child.children[0].name - selected_encoding = encoding_switch.highlighted_child.children[0].name + selected_nos = self.__get_list_view_value('#od-nos-switch') + selected_encoding = self.__get_list_view_value('#od-encoding-switch') try: self.app.install_screen( screen=WorkingScreen( @@ -66,9 +73,9 @@ def __open(self, filename: str = '', content: list[str] = []) -> None: content=content, nos_type=selected_nos, encoding=selected_encoding, - name=screen_name + name=screen_name, ), - name=screen_name + name=screen_name, ) except Exception as err: self.app.uninstall_screen(screen_name) @@ -111,7 +118,6 @@ def open_from_network(self) -> None: return self.lock = True worker = get_current_worker() - platform_ctrl = self.query_one('#od-nos-switch', ListView) hostname_ctrl = self.query_one('#od-nt-host-in', Input) port_ctrl = self.query_one('#od-nt-port-in', Input) username_ctrl = self.query_one('#od-nt-username-in', Input) @@ -120,14 +126,19 @@ def open_from_network(self) -> None: if not worker.is_cancelled: self.app.call_from_thread(self.freeze_callback, True) try: - selected_nos = platform_ctrl.highlighted_child.children[0].name + selected_nos = self.__get_list_view_value('#od-nos-switch') + if not selected_nos: + raise Exception('Platform is not found.') + platform = self.app.settings.platforms.get(selected_nos) + if not platform: + raise Exception('Platform is not found.') loader = NetLoader( host=hostname_ctrl.value, port=int(port_ctrl.value), username=username_ctrl.value, password=password_ctrl.value, proto=switch_ctrl.pressed_index, - platform=selected_nos + platform=platform, ) if not worker.is_cancelled: self.app.logger.debug(f'Opening from a remote host: {hostname_ctrl.value}:{port_ctrl.value}.') @@ -142,17 +153,25 @@ def open_from_network(self) -> None: self.lock = False def compose(self) -> ComposeResult: + import os + platform_index = 0 - if platform := self.app.settings.globals.get('open_dialog_platform', ''): - platform_index = self.app.settings.platforms.index(str(platform)) + if choosen_platform := self.app.settings.current_settings.get('open_dialog_platform', ''): + try: + keys = list(self.app.settings.platforms.keys()) + platform_index = keys.index(str(choosen_platform)) + except ValueError: + self.app.logger.error('Error has occurred during the loading of platform.') + if path := self.app.settings.current_settings.get('default_folder', ''): + path = os.path.expanduser(str(path)) + if os.path.exists(path) and os.path.isdir(path): + self.current_path = Path(path) with Horizontal(id='od-main-container'): with Vertical(id='od-left-block'): yield Static('Select platform:') with ListView(id='od-nos-switch', initial_index=platform_index): - yield ListItem(Label('Juniper JunOS', name='junos')) - yield ListItem(Label('Cisco IOS', name='ios')) - yield ListItem(Label('Cisco NX-OS', name='nxos')) - yield ListItem(Label('Arista EOS', name='eos')) + for platform in self.app.settings.platforms.values(): + yield ListItem(Label(platform.full_name, name=platform.short_name.lower())) yield Static('Select encoding:') with ListView(id='od-encoding-switch'): yield ListItem(Label('UTF-8-SIG', name='utf-8-sig')) @@ -167,7 +186,7 @@ def compose(self) -> ComposeResult: yield Button('OPEN', id='od-open-button', variant='primary') yield Button('REFRESH', id='od-refresh-button', variant='primary') with VerticalScroll(): - yield DirectoryTree(path=str(self.current_path.cwd()), id='od-directory-tree') + yield DirectoryTree(path=str(self.current_path), id='od-directory-tree') yield Input(placeholder='Filename', id='od-main-in') # RIGHT TAB with Vertical(id='od-tab-two', classes='od-disabled'): @@ -179,7 +198,7 @@ def compose(self) -> ComposeResult: classes='od-inputs', validators=[ Length(minimum=1, maximum=256), - ] + ], ) with Horizontal(classes='od-hor-con'): yield Static('Port:', id='od-label-port', classes='od-labels') @@ -189,7 +208,7 @@ def compose(self) -> ComposeResult: classes='od-inputs', validators=[ Number(minimum=1, maximum=65535), - ] + ], ) with Horizontal(classes='od-hor-con'): yield Static('Username:', id='od-label-username', classes='od-labels') @@ -198,7 +217,7 @@ def compose(self) -> ComposeResult: classes='od-inputs', validators=[ Length(minimum=1, maximum=256), - ] + ], ) with Horizontal(classes='od-hor-con'): yield Static('Password:', id='od-label-password', classes='od-labels') @@ -208,7 +227,7 @@ def compose(self) -> ComposeResult: classes='od-inputs', validators=[ Length(minimum=1, maximum=256), - ] + ], ) with Horizontal(classes='od-hor-con'): with RadioSet(id='od-net-switch'): @@ -258,13 +277,13 @@ def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: self.query_one('#od-nt-host-in', Input).focus() def on_radio_set_changed(self, event: RadioSet.Changed) -> None: - control = self.query_one('#od-nt-port-in', Input) + port_ctrl = self.query_one('#od-nt-port-in', Input) if event.radio_set.pressed_index == 0: - if not control.value or control.value == '23' or not control.value.isdigit(): - control.value = '22' + if not port_ctrl.value or port_ctrl.value == '23' or not port_ctrl.value.isdigit(): + port_ctrl.value = '22' else: - if not control.value or control.value == '22' or not control.value.isdigit(): - control.value = '23' + if not port_ctrl.value or port_ctrl.value == '22' or not port_ctrl.value.isdigit(): + port_ctrl.value = '23' def on_list_view_highlighted(self, event: ListView.Highlighted) -> None: if event.item and event.item.children: diff --git a/thymus/tui/styles/main.css b/thymus/tui/styles/main.css deleted file mode 100644 index 9a0573e..0000000 --- a/thymus/tui/styles/main.css +++ /dev/null @@ -1,190 +0,0 @@ -#main-welcome-out { - content-align: center middle; - height: 1fr; -} - -/* OPEN DIALOG DATA */ - -.od-disabled { - display: none; -} - -.od-labels { - width: 15; -} - -.od-hor-con { - height: auto; -} - -.od-inputs { - width: 35; -} - -.od-inputs.-valid { - border: tall $success 60%; -} -.od-inputs.-valid:focus { - border: tall $success; -} - -#od-right-middle-block { - height: auto; - margin-bottom: 1; -} - -#od-tabs { - dock: top; -} - -#od-hor-con-butns { - margin-top: 1; -} - -#od-cap { - margin-bottom: 1; -} - -#od-left-block { - dock: left; - width: 30; - height: 100%; - border-right: #0f2b30; -} - -#od-directory-tree { - scrollbar-gutter: stable; -} - -#od-main-in { - dock: bottom; - } - -#od-error-caption { - margin-left: 1; - color: red; -} - -#od-top-container { - height: auto; - dock: top; -} - -#od-open-button { - margin-left: 1; -} - -#od-refresh-button { - margin-left: 1; -} - -#od-nt-loading { - margin-left: 1; - content-align: left middle; -} - -/* QUIT MODAL DATA */ - -QuitApp { - align: center middle; - background: $primary 30%; -} - -QuitScreen { - align: center middle; - background: $primary 30%; -} - -#qs-dialog { - grid-size: 2; - grid-gutter: 1 2; - grid-rows: 1fr 3; - padding: 0 1; - width: 60; - height: 11; - border: thick $background 80%; - background: $surface; -} - -#qs-question { - column-span: 2; - height: 1fr; - width: 1fr; - content-align: center middle; -} - -#qs-quit { - width: 100%; -} - -#qs-cancel { - width: 100%; -} - -/* CONTEXT LIST SCREEN DATA */ -ContextListScreen { - align: center middle; - background: $primary 30%; -} - -#cm-options { - width: 70%; - height: 80%; -} - -/* ERROR SCREEN DATA */ -ErrorScreen { - align: center middle; - background: $primary 30%; -} - -#es-dialog { - grid-size: 2; - grid-gutter: 1 2; - grid-rows: 1fr 3; - padding: 0 1; - width: 60; - height: 11; - border: thick $background 80%; - background: $surface; -} - -#es-err-msg { - column-span: 2; - height: 1fr; - width: 1fr; - content-align: center middle; -} - -#es-quit { - width: 100%; - content-align: center middle; -} - -/* WS DATA */ - -#ws-main-in { - border: none; - padding-top: 1; - padding-bottom: 1; -} - -#ws-path-line { - background: $secondary-background-lighten-3; -} - -#ws-status-bar { - background: $accent; -} - -#ws-left-sidebar { - dock: left; - width: 30; - height: 100%; - border-right: #0f2b30; -} - -#ws-right-bottom-controls { - height: auto; - dock: bottom; -} diff --git a/thymus/tui/working_screen/__init__.py b/thymus/tui/working_screen/__init__.py index 857274a..ada94ad 100644 --- a/thymus/tui/working_screen/__init__.py +++ b/thymus/tui/working_screen/__init__.py @@ -1,6 +1,4 @@ -__all__ = ( - 'WorkingScreen', -) +__all__ = ('WorkingScreen',) from .working_screen import ( WorkingScreen, diff --git a/thymus/tui/working_screen/extended_input.py b/thymus/tui/working_screen/extended_input.py index 3c7922a..5a4a233 100644 --- a/thymus/tui/working_screen/extended_input.py +++ b/thymus/tui/working_screen/extended_input.py @@ -19,7 +19,7 @@ class ExtendedInput(Input): app: TThymus screen: WorkingScreen - def action_submit(self) -> None: + async def action_submit(self) -> None: if not self.screen.context or not self.value: return if self.value.startswith('global '): @@ -32,7 +32,7 @@ def action_submit(self) -> None: self.screen.query_one('#ws-sections-list', LeftSidebar).clear() self.screen.query_one('#ws-path-line', PathBar).update_path() self.value = '' - super().action_submit() + await super().action_submit() async def on_input_changed(self, message: Input.Changed) -> None: param = 'go |' @@ -44,7 +44,7 @@ async def on_input_changed(self, message: Input.Changed) -> None: param += '|' sidebar.update(param) - def _on_key(self, event: Key) -> None: + async def _on_key(self, event: Key) -> None: sidebar = self.screen.query_one('#ws-sections-list', LeftSidebar) textlog = self.screen.query_one('#ws-main-out', ExtendedTextLog) if event.key == 'tab': @@ -61,7 +61,7 @@ def _on_key(self, event: Key) -> None: self.cursor_position = len(self.value) event.stop() elif event.key == 'up': - if self.app.settings.is_bool_set('sidebar_strict_on_tab'): + if self.app.settings.current_settings['sidebar_strict_on_tab'] in (1, '1', 'on'): textlog.action_scroll_up() else: if self.value: @@ -69,7 +69,7 @@ def _on_key(self, event: Key) -> None: else: textlog.action_scroll_up() elif event.key == 'down': - if self.app.settings.is_bool_set('sidebar_strict_on_tab'): + if self.app.settings.current_settings['sidebar_strict_on_tab'] in (1, '1', 'on'): textlog.action_scroll_down() else: if self.value: @@ -84,4 +84,4 @@ def _on_key(self, event: Key) -> None: if self.screen.context and (next_cmd := self.screen.context.get_input_from_log(forward=False)): self.value = next_cmd self.cursor_position = len(self.value) - super()._on_key(event) + await super()._on_key(event) diff --git a/thymus/tui/working_screen/extended_textlog.py b/thymus/tui/working_screen/extended_textlog.py index f6a7735..59feb08 100644 --- a/thymus/tui/working_screen/extended_textlog.py +++ b/thymus/tui/working_screen/extended_textlog.py @@ -1,11 +1,18 @@ from __future__ import annotations from random import random +from typing import TYPE_CHECKING from textual.widgets import RichLog +if TYPE_CHECKING: + from .working_screen import WorkingScreen + + class ExtendedTextLog(RichLog): + screen: WorkingScreen + def on_mouse_scroll_down(self) -> None: if random() <= 0.25: self.screen.draw() diff --git a/thymus/tui/working_screen/left_sidebar.py b/thymus/tui/working_screen/left_sidebar.py index a6cc17a..b864c26 100644 --- a/thymus/tui/working_screen/left_sidebar.py +++ b/thymus/tui/working_screen/left_sidebar.py @@ -19,9 +19,9 @@ if TYPE_CHECKING: if sys.version_info.major == 3 and sys.version_info.minor >= 9: - from collections.abc import Iterable + from collections.abc import Iterable, Sequence else: - from typing import Iterable + from typing import Iterable, Sequence from .working_screen import WorkingScreen from ...tuier import TThymus @@ -30,11 +30,11 @@ class LeftSidebar(ListView, can_focus=False): app: TThymus screen: WorkingScreen + children: Sequence[ListItem] async def add_element(self, value: str) -> None: name = 'filler' if value == '...' else value for child in self.children: - child: ListItem if child.name == value: return await self.append(ListItem(Label(value), name=name)) @@ -48,6 +48,8 @@ def action_cursor_up(self) -> None: super().action_cursor_up() def update(self, value: str) -> None: + if not self.screen.context: + return if value: if '| ' in value: return @@ -58,11 +60,16 @@ def update(self, value: str) -> None: def get_replacement(self, value: str) -> str: if self.highlighted_child and self.highlighted_child.name == 'filler': return value + if not self.screen.context: + return value value = value.lower() - if self.app.settings.is_bool_set('sidebar_strict_on_tab'): + strict_tab = False + if strict_val := self.app.settings.current_settings.get('sidebar_strict_on_tab', ''): + strict_tab = strict_val in (1, '1', 'on') + if strict_tab: if len(self.children) > 1: try: - elems = [x.name for x in self.children] + elems = [x.name for x in self.children if x.name] if elems[-1] == 'filler': elems = elems[:-1] common = find_common(elems) @@ -75,17 +82,25 @@ def get_replacement(self, value: str) -> str: return value elif len(self.children): if match := self.screen.context.get_virtual_from(value): - return rreplace(value, match, self.highlighted_child.name) if self.highlighted_child else value + return ( + rreplace(value, match, self.highlighted_child.name) + if self.highlighted_child and self.highlighted_child.name + else value + ) else: return value else: if match := self.screen.context.get_virtual_from(value): - return rreplace(value, match, self.highlighted_child.name) if self.highlighted_child else value + return ( + rreplace(value, match, self.highlighted_child.name) + if self.highlighted_child and self.highlighted_child.name + else value + ) return value @work(exclusive=True, exit_on_error=False) async def __update(self, data: Iterable[str]) -> None: - limit = int(self.app.settings.globals['sidebar_limit']) + limit = int(self.app.settings.current_settings['sidebar_limit']) await self.clear() for elem in data: if not limit: diff --git a/thymus/tui/working_screen/path_bar.py b/thymus/tui/working_screen/path_bar.py index a32c729..a1cb008 100644 --- a/thymus/tui/working_screen/path_bar.py +++ b/thymus/tui/working_screen/path_bar.py @@ -18,16 +18,17 @@ class PathBar(Static, can_focus=False): def update_path(self) -> None: current_path = str(self.renderable) - active_path = self.screen.context.prompt - new_value = current_path - if current_path != active_path: - new_value = active_path + '#' - new_value = new_value.replace(self.screen.context.delimiter, '>') - if len(new_value) >= self.size.width: - new_value = new_value[-(self.size.width - 3):] - self.update(f'...{new_value}') - else: - self.update(new_value) + if self.screen.context: + active_path = self.screen.context.prompt + new_value = current_path + if current_path != active_path: + new_value = active_path + '#' + new_value = new_value.replace(self.screen.context.delimiter, '>') + if len(new_value) >= self.size.width: + new_value = new_value[-(self.size.width - 3) :] + self.update(f'...{new_value}') + else: + self.update(new_value) def watch_virtual_size(self, prev: Size, new: Size) -> None: self.update_path() diff --git a/thymus/tui/working_screen/status_bar.py b/thymus/tui/working_screen/status_bar.py index ea7f6c6..f66c8a1 100644 --- a/thymus/tui/working_screen/status_bar.py +++ b/thymus/tui/working_screen/status_bar.py @@ -20,10 +20,12 @@ class StatusBar(Static, can_focus=False): screen: WorkingScreen def update_bar(self) -> None: + if not self.screen.context: + return status: str = '' filename = self.screen.filename - filename_limit = self.app.settings.globals['filename_len'] - theme = self.app.settings.globals['theme'] + filename_limit = int(self.app.settings.current_settings['filename_len']) + theme = str(self.app.settings.current_settings['theme']) if len(filename) > (filename_limit - max(3, int(filename_limit * 0.1))): filename = f'...{filename[-filename_limit:]}' if context_name := self.screen.context.name: @@ -34,7 +36,7 @@ def update_bar(self) -> None: LINES=len(self.screen.context.content), THEME=theme.upper(), ENCODING=self.screen.encoding.upper(), - FILENAME=filename + FILENAME=filename, ) else: status = LINE_UNNAMED.format( @@ -43,7 +45,7 @@ def update_bar(self) -> None: LINES=len(self.screen.context.content), THEME=theme.upper(), ENCODING=self.screen.encoding.upper(), - FILENAME=filename + FILENAME=filename, ) while len(status) > self.size.width: parts = status.split() diff --git a/thymus/tui/working_screen/working_screen.py b/thymus/tui/working_screen/working_screen.py index f8e2c33..bc435a0 100644 --- a/thymus/tui/working_screen/working_screen.py +++ b/thymus/tui/working_screen/working_screen.py @@ -17,13 +17,6 @@ from rich.text import Text from rich.syntax import Syntax -from ...contexts import ( - Context, - JunOSContext, - IOSContext, - EOSContext, - NXOSContext, -) from .extended_textlog import ExtendedTextLog from .extended_input import ExtendedInput from .status_bar import StatusBar @@ -37,17 +30,10 @@ if TYPE_CHECKING: from textual.app import ComposeResult + from ...contexts import Context from ...tuier import TThymus -PLATFORMS: dict[str, type[Context]] = { - 'junos': JunOSContext, - 'ios': IOSContext, - 'eos': EOSContext, - 'nxos': NXOSContext, -} - - class WorkingScreen(Screen): app: TThymus @@ -63,13 +49,7 @@ class WorkingScreen(Screen): draw_data: var[Optional[Response]] = var(None) def __init__( - self, - nos_type: str, - encoding: str, - filename: str = '', - content: list[str] = [], - *args, - **kwargs + self, nos_type: str, encoding: str, filename: str = '', content: list[str] = [], *args, **kwargs ) -> None: super().__init__(*args, **kwargs) self.nos_type = nos_type @@ -90,19 +70,19 @@ def __init__( self.filename = self.screen.name else: self.filename = 'unset' - if context := PLATFORMS.get(nos_type, None): - self.context = context( + if platform := self.app.settings.platforms.get(nos_type): + self.context = platform.context( # type: ignore name='', content=content, encoding=encoding, - settings=getattr(self.app.settings, nos_type), - logger=self.app.logger + settings=platform.current_settings, + logger=self.app.logger, ) if not self.context: err_msg = f'Failed to load context for "{self.filename}".' self.app.logger.error(err_msg) raise Exception(err_msg, 'logged') - self.app.logger.info(f'File "{self.filename}" for the platform "{nos_type}" was opened.') + self.app.logger.info(f'File "{self.filename}" was successfully opened [{nos_type.upper()}].') def compose(self) -> ComposeResult: with Horizontal(id='ws-right-field'): @@ -122,8 +102,10 @@ def on_show(self) -> None: self.print_help() def __draw(self, multiplier: int = 1) -> None: + if not self.draw_data or not self.context: + return control = self.query_one('#ws-main-out', RichLog) - theme = self.app.settings.globals['theme'] + theme = str(self.app.settings.current_settings['theme']) height = control.size.height width = control.size.width - 2 color = control.styles.background.rich_color.name @@ -139,7 +121,7 @@ def __draw(self, multiplier: int = 1) -> None: lexer=self.context.lexer(), theme=theme, code_width=code_width, - background_color=color + background_color=color, ) control.write(syntax, scroll_end=False) elif self.draw_data.rtype == 'rich': @@ -169,17 +151,29 @@ def draw(self, data: Optional[Response] = None) -> None: self.__draw(multiplier) def print_help(self) -> None: + import json + import pathlib + + if not self.context: + return + try: + template_path = pathlib.Path(__file__).resolve().parent.parent.parent + template_path = template_path.joinpath(CONTEXT_HELP) + f = open(str(template_path), encoding='utf-8') + data = json.load(f) + if not data: + return body: list[str] = [] - body.append(CONTEXT_HELP['header'].format(NOS=self.nos_type.upper())) - for k, v in CONTEXT_HELP['singletones'].items(): + body.append(data['header'].format(NOS=self.nos_type.upper())) + for k, v in data['singletones'].items(): if k in self.context.keywords and self.context.keywords[k]: body.append(v.format(CMDS=', '.join(self.context.keywords[k]))) - body.append(CONTEXT_HELP['modificators_header']) - for k, v in CONTEXT_HELP['modificators'].items(): + body.append(data['modificators_header']) + for k, v in data['modificators'].items(): if k in self.context.keywords and self.context.keywords[k]: body.append(v.format(CMDS=', '.join(self.context.keywords[k]))) - body.append(CONTEXT_HELP['footer']) + body.append(data['footer']) r = RichResponse.success(body) self.draw(r) except Exception as err: diff --git a/thymus/tuier.py b/thymus/tuier.py index 7e93be3..65945ed 100644 --- a/thymus/tuier.py +++ b/thymus/tuier.py @@ -14,9 +14,9 @@ from . import ( WELCOME_TEXT, WELCOME_TEXT_LEN, - SCREENS_SAVES_DIR, + SCREENS_DIR, ) -from .app_settings import AppSettings +from .settings import AppSettings from .tui import OpenDialog from .tui.modals import ( QuitApp, @@ -24,36 +24,34 @@ LogsScreen, ) +import logging if TYPE_CHECKING: from textual.events import Resize - import logging - class TThymus(App): CSS_PATH = 'styles/main.css' - SCREENS = { - 'open_file': OpenDialog() - } + SCREENS = {'open_file': OpenDialog()} BINDINGS = [ - ('ctrl+o', 'push_screen(\'open_file\')', 'Open File'), + ('ctrl+o', "push_screen('open_file')", 'Open File'), ('ctrl+n', 'night_mode', 'Night Mode'), ('ctrl+c', 'request_quit', 'Exit'), ('ctrl+s', 'request_contexts', 'Switch Contexts'), ('ctrl+l', 'request_logs', 'Show Logs'), - ('ctrl+p', 'screenshot', 'Screenshot'), + ('ctrl+p', 'make_screenshot', 'Screenshot'), ] working_screens: var[list[str]] = var([]) settings: var[AppSettings] = var(AppSettings()) - logger: var[Optional[logging.Logger]] = var(None) + logger: var[logging.Logger] = var(logging.getLogger(__name__)) is_logo_downscaled: var[bool] = var(False) logo: var[Optional[Static]] = var(None) - def __scale_logo(self, is_down: bool) -> None: + def _scale_logo(self, is_down: bool) -> None: try: text = f'Thymus {app_ver}' if is_down else WELCOME_TEXT.format(app_ver) - self.logo.update(Text(text, justify='center')) + if self.logo: + self.logo.update(Text(text, justify='center')) self.is_logo_downscaled = is_down except Exception as err: self.logger.debug(f'Logo downscaling error: {err}.') @@ -65,14 +63,14 @@ def compose(self) -> ComposeResult: def on_ready(self) -> None: self.logger = self.settings.logger self.logo = self.query_one('#main-welcome-out', Static) - if not self.settings.is_bool_set('night_mode'): + if self.settings.current_settings['night_mode'] in (0, '0', 'off'): self.dark = False def on_resize(self, event: Resize) -> None: if event.virtual_size.width <= WELCOME_TEXT_LEN and not self.is_logo_downscaled: - self.__scale_logo(is_down=True) + self._scale_logo(is_down=True) elif event.virtual_size.width > WELCOME_TEXT_LEN and self.is_logo_downscaled: - self.__scale_logo(is_down=False) + self._scale_logo(is_down=False) def action_request_quit(self) -> None: self.push_screen(QuitApp()) @@ -85,13 +83,13 @@ def action_request_logs(self) -> None: def action_night_mode(self) -> None: self.dark = not self.dark - if self.settings.is_bool_set('night_mode'): - self.settings.process_command('global set night_mode off') - else: + if self.settings.current_settings['night_mode'] in (0, '0', 'off'): self.settings.process_command('global set night_mode on') + else: + self.settings.process_command('global set night_mode off') - def action_screenshot(self) -> None: + def action_make_screenshot(self) -> None: try: - self.save_screenshot(path=SCREENS_SAVES_DIR) + self.save_screenshot(path=SCREENS_DIR) except Exception as err: self.logger.error(f'Cannot save a screenshot: {err}.')