├── .github └── workflows │ ├── build.yml │ ├── docs.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── AUTHORS ├── CHANGELOG ├── LICENSE ├── README.md ├── TODO ├── docs ├── .includes │ ├── admonition-pypi.md │ ├── config-locations.md │ ├── pipx-multiple.md │ ├── quick-install.md │ └── upgrade.md ├── changelog.md ├── commands │ └── index.md ├── guide │ ├── authentication.md │ ├── bulk.md │ ├── configuration.md │ ├── index.md │ ├── installation.md │ ├── logging.md │ ├── migration.md │ └── usage.md ├── index.md ├── plugins │ ├── external-plugins.md │ ├── guide.md │ ├── index.md │ └── local-plugins.md ├── scripts │ ├── __init__.py │ ├── common.py │ ├── gen_cli_data.py │ ├── gen_cli_options.py │ ├── gen_command_list.py │ ├── gen_commands.py │ ├── gen_config_data.py │ ├── gen_formats.py │ ├── gen_ref_pages.py │ ├── run.py │ └── utils │ │ ├── __init__.py │ │ ├── commands.py │ │ └── markup.py ├── static │ └── img │ │ └── plugins │ │ ├── console01.png │ │ ├── help_cat.png │ │ ├── help_command_empty.png │ │ ├── help_empty.png │ │ ├── render_header.png │ │ ├── render_list.png │ │ ├── render_table.png │ │ └── speedscope.png └── templates │ ├── category.md.j2 │ └── command_list.md.j2 ├── mkdocs.yml ├── pyproject.toml ├── resources ├── help.png ├── host-inventory.png ├── hosts.png ├── open-autocomplete.png └── proxies.png ├── scripts └── bump_version.py ├── tests ├── __init__.py ├── commands │ ├── __init__.py │ ├── test_common.py │ └── test_macro.py ├── conftest.py ├── data │ ├── zabbix-cli.conf │ └── zabbix-cli.toml ├── pyzabbix │ ├── __init__.py │ ├── test_client.py │ ├── test_enums.py │ └── test_types.py ├── test_app.py ├── test_auth.py ├── test_bulk.py ├── test_compat.py ├── test_config.py ├── test_console.py ├── test_docs │ ├── __init__.py │ └── test_markup.py ├── test_exceptions.py ├── test_logging.py ├── test_models.py ├── test_patches.py ├── test_prompts.py ├── test_repl.py ├── test_style.py ├── test_update.py ├── test_utils.py └── utils.py ├── uv.lock └── zabbix_cli ├── __about__.py ├── __init__.py ├── __main__.py ├── _patches ├── __init__.py ├── common.py └── typer.py ├── _types.py ├── _v2_compat.py ├── app ├── __init__.py ├── app.py └── plugins.py ├── auth.py ├── bulk.py ├── cache.py ├── commands ├── __init__.py ├── cli.py ├── common │ ├── __init__.py │ └── args.py ├── export.py ├── host.py ├── host_interface.py ├── host_monitoring.py ├── hostgroup.py ├── item.py ├── macro.py ├── maintenance.py ├── media.py ├── problem.py ├── proxy.py ├── results │ ├── __init__.py │ ├── cli.py │ ├── export.py │ ├── host.py │ ├── hostgroup.py │ ├── item.py │ ├── macro.py │ ├── maintenance.py │ ├── problem.py │ ├── proxy.py │ ├── template.py │ ├── templategroup.py │ ├── user.py │ └── usergroup.py ├── template.py ├── templategroup.py ├── user.py └── usergroup.py ├── config ├── __init__.py ├── __main__.py ├── base.py ├── commands.py ├── constants.py ├── model.py ├── run.py └── utils.py ├── dirs.py ├── exceptions.py ├── logs.py ├── main.py ├── models.py ├── output ├── __init__.py ├── console.py ├── formatting │ ├── __init__.py │ ├── bytes.py │ ├── constants.py │ ├── grammar.py │ └── path.py ├── prompts.py ├── render.py └── style.py ├── py.typed ├── pyzabbix ├── __init__.py ├── client.py ├── compat.py ├── enums.py ├── types.py └── utils.py ├── repl ├── __init__.py ├── completer.py └── repl.py ├── scripts ├── __init__.py ├── bulk_execution.py └── init.py ├── state.py ├── table.py ├── update.py └── utils ├── __init__.py ├── args.py ├── commands.py ├── fs.py ├── rich.py └── utils.py /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: build-docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - docs-dev 8 | paths: 9 | - "docs/**" 10 | - "mkdocs.yml" 11 | - ".github/workflows/docs.yml" 12 | - "pyproject.toml" 13 | - "zabbix_cli/**" 14 | 15 | concurrency: 16 | group: docs-deploy 17 | 18 | env: 19 | UV_FROZEN: 1 20 | 21 | jobs: 22 | build: 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | 28 | - name: Set up Python 29 | uses: actions/setup-python@v5 30 | with: 31 | python-version: "3.12" 32 | 33 | - name: Install uv 34 | uses: astral-sh/setup-uv@v2 35 | 36 | - name: Install dependencies 37 | run: uv sync --group docs --no-dev 38 | 39 | - name: Build documentation and publish 40 | run: uv run mkdocs gh-deploy --force 41 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | paths-ignore: 4 | - 'docs/**' 5 | - 'debian/**' 6 | - 'rpm/**' 7 | - 'README.md' 8 | 9 | pull_request: 10 | 11 | 12 | env: 13 | UV_FROZEN: 1 14 | 15 | name: CI 16 | jobs: 17 | test: 18 | name: Test 19 | runs-on: ubuntu-latest 20 | strategy: 21 | matrix: 22 | python-version: 23 | - '3.9' 24 | - '3.10' 25 | - '3.11' 26 | - '3.12' 27 | - '3.13' 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v4 31 | - name: Install Python 32 | uses: actions/setup-python@v5 33 | with: 34 | python-version: ${{ matrix.python-version }} 35 | - name: Install uv 36 | uses: astral-sh/setup-uv@v2 37 | - name: Install dependencies 38 | run: | 39 | uv sync --group test 40 | - name: Test 41 | run: uv run pytest -vv tests 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | 3 | # Build files 4 | dist/ 5 | build/ 6 | *.egg-info/ 7 | 8 | .coverage 9 | 10 | .venv/ 11 | venv/ 12 | 13 | .dccache 14 | .vscode/launch.json 15 | my_tests/ 16 | 17 | # Hatch Pyapp 18 | pyapp 19 | 20 | # Dev commands and directories 21 | zabbix_cli/commands/_dev.py 22 | dev/ 23 | 24 | # Auto-generated docs files 25 | docs/commands/* 26 | !docs/commands/index.md 27 | docs/data 28 | site/ 29 | 30 | # Pyinstaller 31 | *.spec 32 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v5.0.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-yaml 10 | - id: check-added-large-files 11 | - repo: https://github.com/charliermarsh/ruff-pre-commit 12 | rev: "v0.7.0" 13 | hooks: 14 | # Run the linter. 15 | - id: ruff 16 | args: [--fix] 17 | # Run the formatter. 18 | - id: ruff-format 19 | - repo: https://github.com/RobertCraigie/pyright-python 20 | rev: v1.1.386 21 | hooks: 22 | - id: pyright 23 | exclude: ^(tests|scripts|docs)/ 24 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | # Based on `git shortlog --all -es` with duplicates removed 2 | 3 | Alexandru Tică 4 | Andreas Dobloug 5 | Boris Manojlovic 6 | Carl Morten Boger 7 | Emanuele Borin 8 | Fabian Arrotin 9 | Fabian Stelzer 10 | Florian Tham 11 | Fredrik Larsen 12 | Ganesh Hegde 13 | Herdir 14 | Jarle Bjørgeengen 15 | Jean-Baptiste Denis 16 | Jelmer Vernooij 17 | Kim Rioux-Paradis 18 | Logan V 19 | Marius Bakke 20 | Mathieu Marleix 21 | Michael Gindonis 22 | Mustafa Ocak 23 | Mélissa Bertin 24 | Paal Braathen 25 | Peder Hovdan Andresen 26 | Peet Whittaker 27 | Petter Reinholdtsen 28 | Rafael Martinez Guerrero 29 | Retyunskikh Dmitriy 30 | Steve McDuff 31 | Terje Kvernes 32 | Volker Fröhlich 33 | -------------------------------------------------------------------------------- /docs/.includes/admonition-pypi.md: -------------------------------------------------------------------------------- 1 | !!! note "PyPI package name" 2 | We are in the process of acquiring the PyPI project `zabbix-cli`. Until then, installation must be done via the alias `zabbix-cli-uio`. 3 | -------------------------------------------------------------------------------- /docs/.includes/config-locations.md: -------------------------------------------------------------------------------- 1 | === "Linux" 2 | 3 | - `./zabbix-cli.toml` 4 | - `$XDG_CONFIG_HOME/zabbix-cli/zabbix-cli.toml` 5 | - `$XDG_CONFIG_DIRS/zabbix-cli/zabbix-cli.toml` 6 | 7 | === "macOS" 8 | 9 | - `./zabbix-cli.toml` 10 | - `~/Library/Application Support/zabbix-cli/zabbix-cli.toml` 11 | - `~/Library/Preferences/zabbix-cli/zabbix-cli.toml` 12 | 13 | === "Windows" 14 | 15 | - `.\zabbix-cli.toml` 16 | - `%LOCALAPPDATA%\zabbix-cli\zabbix-cli.toml` 17 | - `%APPDATA%\zabbix-cli\zabbix-cli.toml` 18 | -------------------------------------------------------------------------------- /docs/.includes/pipx-multiple.md: -------------------------------------------------------------------------------- 1 | pipx supports installing multiple versions of the same package by giving each installation a custom suffix. For example, if we have an existing installation of Zabbix CLI, and we wish to install a newer version of Zabbix CLI without shadowing or overwriting the existing installation, we can do so: 2 | 3 | ```bash 4 | pipx install zabbix-cli>=3.0.0 --suffix @v3 5 | ``` 6 | 7 | This installs Zabbix CLI >= 3.0.0 with the suffix `@v3`, and we can run it with: 8 | 9 | ```bash 10 | zabbix-cli@v3 11 | ``` 12 | 13 | and the existing installation can be run as usual: 14 | 15 | ```bash 16 | zabbix-cli 17 | ``` 18 | -------------------------------------------------------------------------------- /docs/.includes/quick-install.md: -------------------------------------------------------------------------------- 1 | === "uv" 2 | 3 | Install with [`uv`](https://docs.astral.sh/uv/getting-started/installation/) to avoid conflicts with other Python packages in your system: 4 | 5 | ```bash 6 | uv tool install zabbix-cli-uio 7 | ``` 8 | 9 | To try out Zabbix-CLI without installing it, run it directly with [`uvx`](https://docs.astral.sh/uv/#tool-management): 10 | 11 | ```bash 12 | uvx --from zabbix-cli-uio zabbix-cli 13 | ``` 14 | 15 | {% include-markdown ".includes/admonition-pypi.md" %} 16 | 17 | === "pipx" 18 | 19 | Install with [`pipx`](https://pipx.pypa.io/stable/) to avoid conflicts with other Python packages in your system: 20 | 21 | ```bash 22 | pipx install zabbix-cli-uio 23 | ``` 24 | 25 | {% include-markdown ".includes/admonition-pypi.md" %} 26 | 27 | === "Homebrew" 28 | 29 | You can install `zabbix-cli` with Homebrew: 30 | 31 | ```bash 32 | brew install zabbix-cli 33 | ``` 34 | 35 | !!! warning 36 | The Homebrew package is maintained by a third party. It may be outdated or contain bugs. For the most up to date version, follow the installation instructions for pipx. 37 | 38 | === "Binary" 39 | 40 | Binaries are built with PyInstaller for each release and can be downloaded from the [GitHub releases page](https://github.com/unioslo/zabbix-cli/releases). Download the correct binary for your platform and save it as `zabbix-cli`. 41 | 42 | !!! warning "Linux & macOS" 43 | 44 | Remember to make the binary executable with `chmod +x zabbix-cli`. 45 | -------------------------------------------------------------------------------- /docs/.includes/upgrade.md: -------------------------------------------------------------------------------- 1 | === "uv" 2 | 3 | ```bash 4 | uv tool upgrade zabbix-cli-uio 5 | ``` 6 | 7 | === "pipx" 8 | 9 | ```bash 10 | pipx upgrade zabbix-cli-uio 11 | ``` 12 | 13 | === "Homebrew" 14 | 15 | ```bash 16 | brew upgrade zabbix-cli 17 | ``` 18 | 19 | === "Binary (Automatic)" 20 | 21 | Zabbix-cli has experimental support for updating itself. You can use the `zabbix-cli update` command to update the application to the latest version. 22 | 23 | !!! danger "Write access required" 24 | The application must have write access to itself and the directory it resides in. 25 | 26 | ```bash 27 | zabbix-cli update 28 | ``` 29 | 30 | === "Binary (Manual)" 31 | 32 | The latest binary can be downloaded from [GitHub releases page](https://github.com/unioslo/zabbix-cli/releases). Download the binary for your platform and replace the current one. 33 | 34 | !!! warning "Linux & macOS" 35 | 36 | Remember to make the binary executable with `chmod +x zabbix-cli`. 37 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | This page documents all the changes to released versions of Zabbix CLI. 4 | 5 | {% 6 | include-markdown "../CHANGELOG" 7 | start="" 8 | %} 9 | -------------------------------------------------------------------------------- /docs/commands/index.md: -------------------------------------------------------------------------------- 1 | # Commands 2 | 3 | Each command and its arguments and/or options are documented in the following pages. The commands are grouped by category. 4 | 5 | 6 | -------------------------------------------------------------------------------- /docs/guide/authentication.md: -------------------------------------------------------------------------------- 1 | # Authentication 2 | 3 | Zabbix-cli provides several ways to authenticate. They are tried in the following order: 4 | 5 | 1. [API Token - Environment variables](#environment-variables) 6 | 1. [API Token - Config file](#config-file) 7 | 1. [Session file](#session-file) 8 | 1. [Password - Environment variables](#environment-variables_1) 9 | 1. [Password - Config file](#config-file_1) 10 | 1. [Password - Auth file](#auth-file) 11 | 1. [Password - Prompt](#prompt) 12 | 13 | ## API Token 14 | 15 | The application supports authenticating with an API token. API tokens are created in the Zabbix frontend or via `zabbix-cli create_token`. 16 | 17 | ### Environment variables 18 | 19 | The API token can be set as an environment variable: 20 | 21 | ```bash 22 | export ZABBIX_API_TOKEN="API_TOKEN" 23 | ``` 24 | 25 | ### Config file 26 | 27 | The token can be set directly in the config file: 28 | 29 | ```toml 30 | [api] 31 | auth_token = "API_TOKEN" 32 | ``` 33 | 34 | ## Session file 35 | 36 | The application can store and reuse session tokens between runs. Multiple sessions can be stored at the same time, which allows for switching between different users and/or Zabbix servers seamlessly without having to re-authenticate. 37 | 38 | This feature is enabled by default and configurable via the following options: 39 | 40 | ```toml 41 | [app] 42 | # Enable persistent sessions (default: true) 43 | use_session_file = true 44 | 45 | # Customize token file location (optional) 46 | session_file = "/path/to/auth/token/file" 47 | 48 | # Enforce secure file permissions (600) (default: true, no effect on Windows) 49 | allow_insecure_auth_file = false 50 | ``` 51 | 52 | **How it works:** 53 | 54 | - Log in once with username and password 55 | - Token is automatically saved to the file 56 | - Subsequent runs will use the saved token for authentication 57 | 58 | When `allow_insecure_auth_file` is set to `false`, the application will attempt to set `600` (read/write for owner only) permissions on the token file when creating/updating it. 59 | 60 | ## Username and Password 61 | 62 | The application supports authenticating with a username and password. The password can be set in the config file, an auth file, as environment variables, or prompted for when starting the application. 63 | 64 | ### Environment variables 65 | 66 | The username and password can be set as environment variables: 67 | 68 | ```bash 69 | export ZABBIX_USERNAME="Admin" 70 | export ZABBIX_PASSWORD="zabbix" 71 | ``` 72 | 73 | ### Config file 74 | 75 | The password can be set directly in the config file: 76 | 77 | ```toml 78 | [api] 79 | username = "Admin" 80 | password = "zabbix" 81 | ``` 82 | 83 | ### Auth file 84 | 85 | A file named `.zabbix-cli_auth` can be created in the user's home directory or in the application's data directory. The file should contain a single line of text in the format `USERNAME::PASSWORD`. 86 | 87 | ```bash 88 | echo "Admin::zabbix" > ~/.zabbix-cli_auth 89 | ``` 90 | 91 | The location of the auth file file can be changed in the config file: 92 | 93 | ```toml 94 | [app] 95 | auth_file = "~/.zabbix-cli_auth" 96 | ``` 97 | 98 | ### Prompt 99 | 100 | When all other authentication methods fail, the application will prompt for a username and password. The default username in the prompt can be configured: 101 | 102 | ```toml 103 | [api] 104 | username = "Admin" 105 | ``` 106 | 107 | ## URL 108 | 109 | The URL of the Zabbix API can be set in the config file, as an environment variable, or prompted for when starting the application. 110 | 111 | They are processed in the following order: 112 | 113 | 1. [Environment variables](#environment-variables_2) 114 | 1. [Config file](#config-file_2) 115 | 1. [Prompt](#prompt_1) 116 | 117 | The URL should not include `/api_jsonrpc.php`. 118 | 119 | ### Environment variables 120 | 121 | The URL can also be set as an environment variable: 122 | 123 | ```bash 124 | export ZABBIX_URL="http://zabbix.example.com" 125 | ``` 126 | 127 | ### Config file 128 | 129 | The URL of the Zabbix API can be set in the config file: 130 | 131 | ```toml 132 | 133 | [api] 134 | url = "http://zabbix.example.com" 135 | ``` 136 | 137 | ### Prompt 138 | 139 | When all other methods fail, the application will prompt for the URL of the Zabbix API. 140 | -------------------------------------------------------------------------------- /docs/guide/bulk.md: -------------------------------------------------------------------------------- 1 | # Bulk Operations 2 | 3 | Zabbix-CLI supports performing bulk operations with the `--file` option: 4 | 5 | ```bash 6 | zabbix-cli --file /path/to/commands.txt 7 | ``` 8 | 9 | The `--file` option takes in a file containing commands to run in bulk. Each line in the file should be a separate command. Comments are added by prepending a `#` to the line. 10 | 11 | ```bash 12 | # /path/to/commands.txt 13 | # This is a comment 14 | show_hostgroup "Linux servers" 15 | create_host foobarbaz.example.com --hostgroup "Linux servers,Applications" --proxy .+ --status on --no-default-hostgroup --description "Added in bulk mode" 16 | show_host foobarbaz.example.com 17 | create_hostgroup "My new group" 18 | add_host_to_hostgroup foobarbaz.example.com "My new group" 19 | remove_host_from_hostgroup foobarbaz.example.com "My new group" 20 | remove_hostgroup "My new group" 21 | remove_host foobarbaz.example.com 22 | ``` 23 | 24 | *Example of a bulk operation file that adds a host and a host group, then removes them.* 25 | 26 | ## Errors 27 | 28 | By default, all errors are fatal. If a command fails, the bulk operation is aborted. This behavior can be changed with the `app.bulk_mode` setting in the configuration file: 29 | 30 | ```toml 31 | [app] 32 | bulk_mode = "strict" # strict|continue|skip 33 | ``` 34 | 35 | - `strict`: The operation will stop at the first encountered error. 36 | - `continue`: The operation will continue on errors and report them afterwards. 37 | - `skip`: Same as continue, but invalid lines in the bulk file are also skipped. Errors are completely ignored. 38 | -------------------------------------------------------------------------------- /docs/guide/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | !!! note "Configuration file directory" 4 | The application uses the [platformdirs](https://pypi.org/project/platformdirs/) package to determine the configuration directory. 5 | 6 | The application is configured with a TOML file. The file is created on startup if it doesn't exist. 7 | 8 | The configuration file is searched for in the following locations: 9 | 10 | {% include ".includes/config-locations.md" %} 11 | 12 | 13 | 14 | ## Create a config 15 | 16 | The configuration file is automatically created when the application is started for the first time. 17 | 18 | The config file can also manually be created with the `init` command: 19 | 20 | ```bash 21 | zabbix-cli init 22 | ``` 23 | 24 | The application will print the location of the created configuration file. 25 | 26 | To bootstrap the config with a URL and username, use the options `--url` and `--user`: 27 | 28 | ```bash 29 | zabbix-cli init --url https://zabbix.example.com --user Admin 30 | ``` 31 | 32 | To overwrite an existing configuration file, use the `--overwrite` option: 33 | 34 | ``` 35 | zabbix-cli init --overwrite 36 | ``` 37 | 38 | ## Config directory 39 | 40 | The default configuration directory can be opened in the system's file manager with the `open` command: 41 | 42 | ```bash 43 | zabbix-cli open config 44 | ``` 45 | 46 | To print the path instead of opening it, use the `--path` option: 47 | 48 | ```bash 49 | zabbix-cli open config --path 50 | ``` 51 | 52 | ## Show config 53 | 54 | The contents of the current configuration file can be displayed with `show_config`: 55 | 56 | ```bash 57 | zabbix-cli show_config 58 | ``` 59 | 60 | ## Sample config 61 | 62 | A sample configuration file can be printed to the terminal with the `sample_config` command. This can be redirected to a file to create a configuration file in an arbitrary location: 63 | 64 | ``` 65 | zabbix-cli sample_config > /path/to/config.toml 66 | ``` 67 | 68 | A more convoluted way of creating a default config file in the default location would be: 69 | 70 | ``` 71 | zabbix-cli sample_config > "$(zabbix-cli open --path config)/zabbix-cli.toml" 72 | ``` 73 | 74 | The created config looks like this: 75 | 76 | ```toml 77 | {% include "data/sample_config.toml" %} 78 | ``` 79 | 80 | ## Options 81 | 82 | {% macro render_option(option) %} 83 | {% if option.is_model %} 84 | 85 | ### `{{ option.name }}` 86 | 87 | {{ option.description }} 88 | 89 | {% for field in option.fields %} 90 | {{ render_option(field) }} 91 | {% endfor %} 92 | 93 | {% else %} 94 | 95 | #### `{{ option.name }}` 96 | 97 | {{ option.description }} 98 | 99 | Type: `{{ option.type }}` 100 | 101 | {% if option.default %} 102 | Default: `{{ option.default }}` 103 | {% endif %} 104 | 105 | {% if option.choices_str %} 106 | Choices: `{{ option.choices_str }}` 107 | {% endif %} 108 | 109 | {% if option.required %} 110 | Required: `true` 111 | {% endif %} 112 | 113 | {% if option.parents_str and option.example %} 114 | **Example:** 115 | 116 | ```toml 117 | {{ option.example }} 118 | ``` 119 | 120 | {% endif %} 121 | 122 | ---- 123 | 124 | {% endif %} 125 | {% endmacro %} 126 | {% for option in config_options.fields %} 127 | {{ render_option(option) }} 128 | {% endfor %} 129 | -------------------------------------------------------------------------------- /docs/guide/index.md: -------------------------------------------------------------------------------- 1 | # User Guide 2 | 3 | Zabbix CLI is an application that provides a command line interface for interacting with the Zabbix API. Once installed, it can be invoked with `zabbix-cli`. 4 | 5 | The application is intended to provide a more user-friendly interface to the Zabbix API, and make it easier to automate common tasks. 6 | -------------------------------------------------------------------------------- /docs/guide/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | The application is primarily distributed with `pip`, but other installation methods are also available. 4 | 5 | ## Install 6 | 7 | {% include-markdown ".includes/quick-install.md" %} 8 | 9 | ## Upgrade 10 | 11 | The upgrade process depends on the chosen installation method. 12 | 13 | {% include ".includes/upgrade.md" %} 14 | -------------------------------------------------------------------------------- /docs/guide/logging.md: -------------------------------------------------------------------------------- 1 | # Logging 2 | 3 | The application supports logging to a file or directly to the terminal. By default, file logging is enabled and set to the `ERROR` level. 4 | 5 | ## Enable/disable logging 6 | 7 | Logging is enabled by default. To disable logging, set the `enabled` option to `false` in the configuration file: 8 | 9 | ```toml 10 | [logging] 11 | enabled = true 12 | ``` 13 | 14 | ## Levels 15 | 16 | The application only logs messages with a level equal to or higher than the configured level. By default, the level is set to `ERROR`. The available levels are: 17 | 18 | - `DEBUG` 19 | - `INFO` 20 | - `WARNING` 21 | - `ERROR` 22 | - `CRITICAL` 23 | 24 | The level can be set in the configuration file: 25 | 26 | ```toml 27 | [logging] 28 | level = "DEBUG" 29 | ``` 30 | 31 | ## Log file 32 | 33 | The default location of the log file is a file named `zabbix-cli.log` in the application's logs directory. 34 | 35 | The log file location can be changed in the configuration file: 36 | 37 | ```toml 38 | [logging] 39 | log_file = "/path/to/zabbix-cli.log" 40 | ``` 41 | 42 | The default logs directory can be opened with the command: 43 | 44 | ```bash 45 | zabbix-cli open logs 46 | ``` 47 | 48 | ## Log to terminal 49 | 50 | !!! warning "Verbose output" 51 | Logging to the terminal can produce a lot of output, especially when the log level is set to `DEBUG`. Furthermore, some of the output messages may be shown twice, as they are printed once by the application and once by the logging library. 52 | 53 | If the `log_file` option is set to an empty string or an invalid file path, the application will log to the terminal instead of a file. 54 | 55 | ```toml 56 | [logging] 57 | log_file = "" 58 | ``` 59 | -------------------------------------------------------------------------------- /docs/guide/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | ## Interactive mode 4 | 5 | Invoking `zabbix-cli` without any arguments will start the application in an interactive shell. This is the default mode of operation, and is the most user-friendly way to use the application. 6 | 7 | ```bash 8 | zabbix-cli 9 | ``` 10 | 11 | Within the interactive shell, commands can be entered and executed. Command and argument hints, tab autocompletion and history are supported out of the box. 12 | 13 | ``` 14 | % zabbix-cli 15 | ╭────────────────────────────────────────────────────────────╮ 16 | │ Welcome to the Zabbix command-line interface (v3.0.0) │ 17 | │ Connected to server http://localhost:8082 (v7.0.0) │ 18 | ╰────────────────────────────────────────────────────────────╯ 19 | Type --help to list commands, :h for REPL help, :q to exit. 20 | > 21 | ``` 22 | 23 | ## Single command mode 24 | 25 | Commands can also be invoked directly from the command line. This is useful for scripting and automation, as well for just running one-off commands. 26 | 27 | ```bash 28 | zabbix-cli show_hostgroup "Linux servers" 29 | ``` 30 | 31 | ## Bulk mode 32 | 33 | Zabbix CLI also supports running commands sourced from a file with the `--file` option. This is useful for running a series of commands in bulk. 34 | 35 | The file should contain one command per line, with arguments separated by spaces. Comments can be added with `#`. 36 | 37 | ``` 38 | $ cat /path/to/commands.txt 39 | # This is a comment 40 | show_hostgroup "Linux servers" 41 | create_host --host "foo.example.com" --hostgroup "Linux servers,Applications" --proxy .+ --status on --no-default-hostgroup --description "Added in bulk mode" 42 | create_hostgroup "My new group" 43 | add_host_to_hostgroup foo.example.com "My new group" 44 | ``` 45 | 46 | ``` 47 | $ zabbix-cli --file /path/to/commands.txt 48 | ╭────┬───────────────┬───────┬───────╮ 49 | │ ID │ Name │ Flag │ Hosts │ 50 | ├────┼───────────────┼───────┼───────┤ 51 | │ 2 │ Linux servers │ Plain │ │ 52 | ╰────┴───────────────┴───────┴───────╯ 53 | ✓ Created host 'foobarbaz.example.com' (10634) 54 | ✓ Created host group My new group (31). 55 | ╭──────────────┬───────────────────────╮ 56 | │ Hostgroup │ Hosts │ 57 | ├──────────────┼───────────────────────┤ 58 | │ My new group │ foobarbaz.example.com │ 59 | ╰──────────────┴───────────────────────╯ 60 | ✓ Added 1 host to 1 host group. 61 | ``` 62 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Zabbix CLI 2 | 3 | Zabbix CLI is a command line application for interacting with Zabbix version 6 or later. It is written in Python and uses the Zabbix API to interact with a Zabbix server. 4 | 5 | ## Installation 6 | 7 | {% include-markdown ".includes/quick-install.md" %} 8 | 9 | For the next steps or ways to customize the installation, head over to the detailed [installation](./guide/installation.md) guide. 10 | -------------------------------------------------------------------------------- /docs/plugins/external-plugins.md: -------------------------------------------------------------------------------- 1 | # External plugins 2 | 3 | !!! important 4 | This page assumes you have read the [Writing plugins](./guide.md) page to understand the basics of writing plugins. 5 | 6 | External plugins are plugins that are packaged as Python packages and can be installed with Pip. Using [`pyproject.toml` entry points](https://packaging.python.org/en/latest/specifications/entry-points/), the application can automatically discover and load these plugins. 7 | 8 | A complete example of an external plugin can be found here: 9 | 10 | ## Packaging 11 | 12 | Assuming you have written a plugin module as outlined in[Writing plugins](./guide.md), you can package it as a Python package that defines an entry point for Zabbix-CLI to discover. Similar to local plugins, the entry point is a Python file or module that contains the plugin's functionality, except for external plugins, the entry point is defined in the `pyproject.toml` file - _not_ the configuration file. 13 | 14 | ### Directory structure 15 | 16 | The plugin package should have the following directory structure: 17 | 18 | ```plaintext 19 | . 20 | ├── my_plugin/ 21 | │ ├── __init__.py 22 | │ └── plugin.py 23 | └── pyproject.toml 24 | ``` 25 | 26 | Alternatively, if using the src layout: 27 | 28 | ```plaintext 29 | . 30 | ├── src/ 31 | │ └── my_plugin/ 32 | │ ├── __init__.py 33 | │ └── plugin.py 34 | └── pyproject.toml 35 | ``` 36 | 37 | ### pyproject.toml 38 | 39 | The package must contain a `pyproject.toml` file that instructs your package manager how to build and install the package. The following is a good starting point for a project using `hatchling` as the build backend: 40 | 41 | ```toml 42 | [build-system] 43 | requires = ["hatchling"] 44 | build-backend = "hatchling.build" 45 | 46 | [project] 47 | name = "my_plugin" 48 | authors = [ 49 | {name = "Firstname Lastname", email = "mail@example.com"}, 50 | ] 51 | version = "0.1.0" 52 | description = "My first Zabbix CLI plugin" 53 | readme = "README.md" 54 | requires-python = ">=3.9" 55 | license = "MIT" 56 | dependencies = [ 57 | "zabbix-cli@git+https://github.com/unioslo/zabbix-cli.git", 58 | ] 59 | 60 | [tool.hatch.metadata] 61 | allow-direct-references = true 62 | 63 | [project.entry-points.'zabbix-cli.plugins'] 64 | my_plugin = "my_plugin.plugin" 65 | ``` 66 | 67 | !!! info "Build backend" 68 | If you prefer setuptools, you can omit the `[tool.hatch.metadata]` section and replace the `[build-system]` section with the following: 69 | ```toml 70 | [build-system] 71 | requires = ["setuptools", "setuptools-scm"] 72 | build-backend = "setuptools.build_meta" 73 | ``` 74 | 75 | #### Declaring the entry point 76 | 77 | In your plugin's `pyproject.toml` file, you _must_ declare an entry point that Zabbix-CLI can find and load. The entry point is defined in the `[project.entry-points.'zabbix-cli.plugins']` section, where the key is the name of the plugin and the value is the import path to your plugin module. Recall that we defined a directory structure like this: 78 | 79 | ```plaintext 80 | . 81 | ├── my_plugin/ 82 | │ ├── __init__.py 83 | │ └── plugin.py 84 | └── pyproject.toml 85 | ``` 86 | 87 | In which case, the entry point should be defined as follows: 88 | 89 | ```toml 90 | [project.entry-points.'zabbix-cli.plugins'] 91 | my_plugin = "my_plugin.plugin" 92 | ``` 93 | 94 | ## Configuration 95 | 96 | !!! info "Loading external plugins" 97 | External plugins are automatically discovered by the application and do not require manual configuration to be loaded. 98 | 99 | Much like local plugins, external plugins define their configuration in the application's configuration file. However, the configuration is not used to _load_ the plugin, and is only used to provide additional configuration options or customization. 100 | 101 | The name of the plugin in the configuration file must match the name used in the entry point section in the `pyproject.toml` file. Given that we used the name `my_plugin` in the entrypoint section, its configuration should look like this in the Zabbix-CLI configuration file: 102 | 103 | ```toml 104 | [plugins.my_plugin] 105 | # module must be omitted for external plugins 106 | enabled = true 107 | extra_option_1 = "Some value" 108 | extra_option_2 = 42 109 | ``` 110 | 111 | !!! warning "Local plugin migration" 112 | If rewriting a local plugin as an external one, remember to remove the `module` key from the plugin's configuration. If a `module` key is present, the application will attempt to load the plugin as a local plugin. 113 | 114 | ## Installation 115 | 116 | How to install the plugins depends on how Zabbix-CLI is installed. The plugin must be installed in the same Python environment as Zabbix-CLI, which is different for each installation method. 117 | 118 | ### uv 119 | 120 | `uv` can install plugins using the same `uv tool install` command, but with the `--with` flag: 121 | 122 | ```bash 123 | uv tool install zabbix-cli-uio --with my_plugin 124 | ``` 125 | 126 | ### pipx 127 | 128 | `pipx` Zabbix-CLI installations require the plugin to be injected into the environment: 129 | 130 | ```bash 131 | pipx install zabbix-cli-uio 132 | pipx inject zabbix-cli-uio my_plugin 133 | ``` 134 | 135 | ### pip 136 | 137 | If Zabbix-CLI is installed with `pip`, the plugin can be installed as a regular Python package: 138 | 139 | ```bash 140 | pip install my_plugin 141 | ``` 142 | -------------------------------------------------------------------------------- /docs/plugins/index.md: -------------------------------------------------------------------------------- 1 | # Plugins 2 | 3 | !!! warning "Work in progress" 4 | The plugin system is still under development and may change in future releases. 5 | 6 | The functionality of the application can be extended with user-defined plugins. Plugins can be used to add new commands, modify existing commands, or add new functionality to the application. Plugins can installed as local Python modules or external Python packages. 7 | 8 | ## Local plugins 9 | 10 | Local plugins are local Python modules (files) that are loaded by the application. They are the easiest way to add new functionality to the application, but are harder to distribute and share in a consistent manner. They are not automatically discovered by the application, and must be manually configured in the configuration file. 11 | 12 | See the [local plugins](./local-plugins.md) page for more information. 13 | 14 | ## External plugins 15 | 16 | External plugins are Python packages that can be installed with Pip and are automatically discovered by the application. They are easier to distribute and share, but require more effort on the part of the plugin author to create and maintain. 17 | 18 | See the [external plugins](./external-plugins.md) page for more information. 19 | 20 | ## Choosing the correct plugin type 21 | 22 | Both local and external plugins are essentially written in the same manner, following the application’s guidelines for plugin development outlined in [Writing plugins](./guide.md). This common foundation ensures that the core functionality is consistent whether the plugin is distributed as a local module or an external package. 23 | 24 | The difference lies primarily in how they are packaged for distribution and how the application loads them. While local plugins require manual configuration to be recognized by the application, external plugins are designed to be discovered automatically once installed. 25 | 26 | An easy way to decide which type of plugin to use is to consider whether you intend to share your plugin or not. If you do, an external plugin is likely the way to go. If you are developing a plugin for personal use or for a specific environment, a local plugin may be more appropriate. 27 | -------------------------------------------------------------------------------- /docs/plugins/local-plugins.md: -------------------------------------------------------------------------------- 1 | # Local plugins 2 | 3 | !!! important 4 | This page assumes you have read the [Writing plugins](./guide.md) page to understand the basics of writing plugins. 5 | 6 | A local plugin is a Python module that is loaded by the application on startup. It _must_ be manually configured in the configuration file for the application to find it. 7 | 8 | ## Directory structure 9 | 10 | Given your plugin is structured like this: 11 | 12 | ```plaintext 13 | /path/to/ 14 | └── my_plugin/ 15 | ├── __init__.py 16 | └── plugin.py 17 | ``` 18 | 19 | You can add the following to your configuration file: 20 | 21 | ```toml 22 | [plugins.my_plugin] 23 | module = "/path/to/my_plugin/plugin.py" 24 | # or 25 | # module = "my_plugin.plugin" 26 | ``` 27 | 28 | An absolute path to the plugin file is preferred, but a Python module path can also be used. The differences are outlined below. 29 | 30 | ### File path 31 | 32 | It is recommended to use an absolute path to the plugin file. This ensures that the application can find the plugin regardless of the current working directory. The path should point to the plugin file itself, not the directory containing it. 33 | 34 | ### Module path 35 | 36 | One can also use a Python module path to the plugin file. This is useful if the plugin is part of a larger Python package. The path must be available in the Python path (`$PYTHONPATH`) for the application to find it. The import path can point to the plugin file itself or the directory containing it as long as `__init__.py` is present and imports the plugin file. 37 | -------------------------------------------------------------------------------- /docs/scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unioslo/zabbix-cli/89a4eac093114b8d39cdc32bd630d40fabe9affc/docs/scripts/__init__.py -------------------------------------------------------------------------------- /docs/scripts/common.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | 5 | from zabbix_cli.dirs import DIRS 6 | from zabbix_cli.dirs import Directory 7 | 8 | # Directory of all docs files 9 | DOC_DIR = Path(__file__).parent.parent 10 | 11 | # Directory of data files for Jinja2 templates 12 | DATA_DIR = DOC_DIR / "data" 13 | if not DATA_DIR.exists(): 14 | DATA_DIR.mkdir(parents=True) 15 | 16 | # Directory of Jinja2 templates 17 | TEMPLATES_DIR = DOC_DIR / "templates" 18 | 19 | # Directory of generated command doc pages 20 | COMMANDS_DIR = DOC_DIR / "commands" 21 | if not COMMANDS_DIR.exists(): 22 | COMMANDS_DIR.mkdir(parents=True) 23 | 24 | 25 | def sanitize_dirname(d: Directory) -> str: 26 | """Sanitize directory name for use in filenames.""" 27 | return f"{d.name.lower().replace(' ', '_')}_dir" 28 | 29 | 30 | def add_path_placeholders(s: str) -> str: 31 | """Add placeholders for file paths used by the application in a string. 32 | 33 | Enables somewhat consistent file paths in the documentation 34 | regardless of the runner environment. 35 | """ 36 | for directory in DIRS: 37 | # Naive string replacement, then clean up double slashes if any 38 | s = s.replace(f"{directory.path}", f"/path/to/{sanitize_dirname(directory)}") 39 | s = s.replace("//", "/") 40 | return s 41 | -------------------------------------------------------------------------------- /docs/scripts/gen_cli_data.py: -------------------------------------------------------------------------------- 1 | """Script that runs various CLI commands and collects the result for use 2 | in the documentation. 3 | 4 | The commands are run in a limited environment (no color, limited width) to 5 | make the output more readable in the documentation. 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | import os 11 | import subprocess 12 | import sys 13 | from pathlib import Path 14 | from typing import NamedTuple 15 | from typing import Optional 16 | from typing import Protocol 17 | 18 | import tomli 19 | import tomli_w 20 | 21 | sys.path.append(Path(__file__).parent.as_posix()) 22 | 23 | 24 | from common import DATA_DIR # noqa: I001 25 | from common import add_path_placeholders 26 | 27 | # Set up environment variables for the CLI 28 | env = os.environ.copy() 29 | env["LINES"] = "40" 30 | env["COLUMNS"] = "90" # limit width so it looks nicer in MD code blocks 31 | env["TERM"] = "dumb" # disable color output (color codes mangle it) 32 | 33 | 34 | class CommandCallback(Protocol): 35 | def __call__(self, output: str) -> str: ... 36 | 37 | 38 | class Command(NamedTuple): 39 | command: list[str] 40 | filename: str 41 | callback: Optional[CommandCallback] = None 42 | 43 | 44 | def add_config_bogus_defaults(output: str) -> str: 45 | """Give bogus defaults to certain config values.""" 46 | config = tomli.loads(output) 47 | # TODO: replace local username with a default value 48 | out = tomli_w.dumps(config) 49 | out = add_path_placeholders(out) 50 | return out 51 | 52 | 53 | COMMAND_HELP = Command(["zabbix-cli", "--help"], "help.txt") 54 | COMMAND_SAMPLE_CONFIG = Command( 55 | ["zabbix-cli", "sample_config"], 56 | "sample_config.toml", 57 | callback=add_config_bogus_defaults, 58 | ) 59 | 60 | # List of commands to run 61 | COMMANDS = [ 62 | COMMAND_HELP, 63 | COMMAND_SAMPLE_CONFIG, 64 | ] 65 | 66 | 67 | def main() -> None: 68 | """Run the commands and save the output to files.""" 69 | for cmd in COMMANDS: 70 | output = subprocess.check_output(cmd.command, env=env).decode("utf-8") 71 | if cmd.callback: 72 | output = cmd.callback(output) 73 | 74 | with open(DATA_DIR / cmd.filename, "w") as f: 75 | f.write(output) 76 | 77 | 78 | if __name__ == "__main__": 79 | main() 80 | -------------------------------------------------------------------------------- /docs/scripts/gen_cli_options.py: -------------------------------------------------------------------------------- 1 | """Generates a YAML file containing all the global options for the CLI.""" 2 | 3 | from __future__ import annotations 4 | 5 | import sys 6 | from pathlib import Path 7 | from typing import NamedTuple 8 | 9 | import yaml # type: ignore 10 | from zabbix_cli.main import app 11 | 12 | sys.path.append(Path(__file__).parent.as_posix()) 13 | from common import DATA_DIR # noqa 14 | from utils.commands import get_app_callback_options # noqa 15 | 16 | 17 | def convert_envvar_value(text: str | list[str] | None) -> list[str] | None: 18 | # The envvars might actually be instances of `harbor_cli.config.EnvVar`, 19 | # which the YAML writer does not convert to strings. Hence `str(...)` 20 | if isinstance(text, list): 21 | return [str(t) for t in text] 22 | elif isinstance(text, str): 23 | # convert to str (might be enum) and wrap in list 24 | return [str(text)] 25 | elif text is None: 26 | return [] 27 | else: 28 | raise ValueError(f"Unexpected option env var type {type(text)} ({text})") 29 | 30 | 31 | # name it OptInfo to avoid confusion with typer.models.OptionInfo 32 | class OptInfo(NamedTuple): 33 | params: list[str] 34 | help: str | None 35 | envvar: list[str] 36 | config_value: str | None 37 | 38 | @property 39 | def fragment(self) -> str | None: 40 | if self.config_value is None: 41 | return None 42 | return self.config_value.replace(".", "") 43 | 44 | def to_dict(self) -> dict[str, str | list[str] | None]: 45 | return { 46 | "params": ", ".join(f"`{p}`" for p in self.params), 47 | "help": self.help or "", 48 | "envvar": convert_envvar_value(self.envvar), 49 | "config_value": self.config_value, 50 | "fragment": self.fragment, 51 | } 52 | 53 | 54 | def main() -> None: 55 | options = [] # type: list[OptInfo] 56 | for option in get_app_callback_options(app): 57 | if not option.param_decls: 58 | continue 59 | conf_value = None 60 | if hasattr(option, "config_override"): 61 | conf_value = option.config_override 62 | h = option._help_original if hasattr(option, "_help_original") else option.help 63 | o = OptInfo( 64 | params=option.param_decls, 65 | help=h, 66 | envvar=option.envvar, 67 | config_value=conf_value, 68 | ) 69 | options.append(o) 70 | 71 | to_dump = [o.to_dict() for o in options] 72 | 73 | with open(DATA_DIR / "options.yaml", "w") as f: 74 | yaml.dump(to_dump, f, sort_keys=False) 75 | 76 | 77 | if __name__ == "__main__": 78 | main() 79 | -------------------------------------------------------------------------------- /docs/scripts/gen_command_list.py: -------------------------------------------------------------------------------- 1 | """Generate documentation of commands and categories. 2 | 3 | Generates the following files: 4 | 5 | - `commandlist.yaml`: List with names of all commands. 6 | - `commands.yaml`: Mapping of all categories to detailed information 7 | about each command. 8 | """ 9 | 10 | from __future__ import annotations 11 | 12 | import sys 13 | from pathlib import Path 14 | from typing import Any 15 | 16 | import yaml # type: ignore 17 | from zabbix_cli.app import app 18 | 19 | sys.path.append(Path(__file__).parent.as_posix()) 20 | from common import DATA_DIR # noqa 21 | from utils.commands import get_app_commands # noqa: E402 22 | 23 | 24 | def main() -> None: 25 | commands = get_app_commands(app) 26 | command_names = [c.name for c in commands] 27 | 28 | categories: dict[str, list[dict[str, Any]]] = {} 29 | for command in commands: 30 | category = command.category or "" 31 | if category not in categories: 32 | categories[category] = [] 33 | cmd_dict = command.model_dump(mode="json") 34 | # cmd_dict["usage"] = command.usage 35 | categories[category].append(cmd_dict) 36 | 37 | with open(DATA_DIR / "commands.yaml", "w") as f: 38 | yaml.dump(categories, f, sort_keys=True) 39 | 40 | with open(DATA_DIR / "commandlist.yaml", "w") as f: 41 | yaml.dump(command_names, f, sort_keys=True) 42 | 43 | 44 | if __name__ == "__main__": 45 | main() 46 | -------------------------------------------------------------------------------- /docs/scripts/gen_commands.py: -------------------------------------------------------------------------------- 1 | """Generate the code reference pages and navigation.""" 2 | 3 | from __future__ import annotations 4 | 5 | import sys 6 | from pathlib import Path 7 | from typing import Any 8 | 9 | import jinja2 10 | import yaml # type: ignore 11 | from sanitize_filename import sanitize 12 | from zabbix_cli.app import app 13 | 14 | sys.path.append(Path(__file__).parent.as_posix()) 15 | sys.path.append(Path(__file__).parent.parent.parent.as_posix()) 16 | 17 | from common import COMMANDS_DIR # noqa 18 | from common import DATA_DIR # noqa 19 | from common import TEMPLATES_DIR # noqa 20 | from utils.commands import CommandSummary # noqa: E402 21 | from utils.commands import get_app_commands # noqa: E402 22 | 23 | 24 | def gen_command_list(commands: list[CommandSummary]) -> None: 25 | """Generates a YAML file with a list of the names of all commands.""" 26 | command_names = [c.name for c in commands] 27 | with open(DATA_DIR / "commandlist.yaml", "w") as f: 28 | yaml.dump(command_names, f, sort_keys=False) 29 | 30 | 31 | def gen_category_command_map(commands: list[CommandSummary]) -> None: 32 | """Generates a YAML file with all categories and detailed information 33 | about their respective commands. 34 | """ 35 | categories: dict[str, list[dict[str, Any]]] = {} 36 | for command in commands: 37 | category = command.category or "" 38 | if category not in categories: 39 | categories[category] = [] 40 | cmd_dict = command.model_dump(mode="json") 41 | # cmd_dict["usage"] = command.usage 42 | categories[category].append(cmd_dict) 43 | 44 | with open(DATA_DIR / "commands.yaml", "w") as f: 45 | yaml.dump(categories, f, sort_keys=True) 46 | 47 | 48 | def gen_category_pages(commands: list[CommandSummary]) -> None: 49 | """Renders markdown pages for each category with detailed information 50 | about each command. 51 | """ 52 | categories: dict[str, list[CommandSummary]] = {} 53 | for command in commands: 54 | if command.hidden: 55 | continue 56 | category = command.category or command.name 57 | if category not in categories: 58 | categories[category] = [] 59 | categories[category].append(command) 60 | 61 | loader = jinja2.FileSystemLoader(searchpath=TEMPLATES_DIR) 62 | env = jinja2.Environment(loader=loader) 63 | 64 | # Render each individual command page 65 | pages = {} # type: dict[str, str] # {category: filename} 66 | for category_name, cmds in categories.items(): 67 | template = env.get_template("category.md.j2") 68 | filename = sanitize(category_name.replace(" ", "_")) 69 | filepath = COMMANDS_DIR / f"{filename}.md" 70 | with open(filepath, "w") as f: 71 | f.write(template.render(category=category_name, commands=cmds)) 72 | pages[category_name] = filename 73 | 74 | 75 | def main() -> None: 76 | commands = get_app_commands(app) 77 | gen_category_command_map(commands) 78 | gen_command_list(commands) 79 | gen_category_pages(commands) 80 | 81 | 82 | if __name__ == "__main__": 83 | main() 84 | -------------------------------------------------------------------------------- /docs/scripts/gen_formats.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from pathlib import Path 5 | 6 | import yaml # type: ignore 7 | from zabbix_cli.config.constants import OutputFormat 8 | 9 | sys.path.append(Path(__file__).parent.as_posix()) 10 | 11 | from common import DATA_DIR # noqa 12 | 13 | 14 | def main() -> None: 15 | fmts = [fmt.value for fmt in OutputFormat] 16 | 17 | with open(DATA_DIR / "formats.yaml", "w") as f: 18 | yaml.dump(fmts, f, default_flow_style=False) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /docs/scripts/gen_ref_pages.py: -------------------------------------------------------------------------------- 1 | """Generate the code reference pages.""" 2 | 3 | from __future__ import annotations 4 | 5 | from pathlib import Path 6 | 7 | import mkdocs_gen_files 8 | 9 | src = Path(__file__).parent.parent / "src" 10 | 11 | for path in sorted(src.rglob("*.py")): 12 | module_path = path.relative_to(src).with_suffix("") 13 | doc_path = path.relative_to(src).with_suffix(".md") 14 | full_doc_path = Path("reference", doc_path) 15 | 16 | parts = tuple(module_path.parts) 17 | 18 | if parts[-1] == "__init__": 19 | parts = parts[:-1] 20 | elif parts[-1] == "__main__": 21 | continue 22 | 23 | with mkdocs_gen_files.open(full_doc_path, "w") as fd: 24 | identifier = ".".join(parts) 25 | print("::: " + identifier, file=fd) 26 | 27 | mkdocs_gen_files.set_edit_path(full_doc_path, path) 28 | -------------------------------------------------------------------------------- /docs/scripts/run.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from pathlib import Path 5 | from typing import Any 6 | 7 | sys.path.append(Path(__file__).parent.as_posix()) 8 | 9 | import docs.scripts.gen_commands as gen_commands # noqa 10 | import gen_cli_data # noqa 11 | import gen_cli_options # noqa 12 | import gen_command_list # noqa 13 | import gen_formats # noqa 14 | import gen_config_data # noqa 15 | 16 | 17 | def main(*args: Any, **kwargs: Any) -> None: 18 | for mod in [ 19 | gen_cli_data, 20 | gen_cli_options, 21 | gen_command_list, 22 | gen_commands, 23 | gen_formats, 24 | gen_config_data, 25 | ]: 26 | mod.main() 27 | -------------------------------------------------------------------------------- /docs/scripts/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unioslo/zabbix-cli/89a4eac093114b8d39cdc32bd630d40fabe9affc/docs/scripts/utils/__init__.py -------------------------------------------------------------------------------- /docs/scripts/utils/markup.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import itertools 4 | from dataclasses import dataclass 5 | from functools import cmp_to_key 6 | 7 | from rich.text import Text 8 | from zabbix_cli.output.style import CodeBlockStyle 9 | from zabbix_cli.output.style import CodeStyle 10 | 11 | CODEBLOCK_STYLES = list(CodeBlockStyle) 12 | CODE_STYLES = list(CodeStyle) 13 | CODEBLOCK_LANGS = { 14 | "python": "py", 15 | } 16 | 17 | 18 | @dataclass 19 | class MarkdownSpan: 20 | start: int 21 | end: int 22 | italic: bool = False 23 | bold: bool = False 24 | code: bool = False 25 | codeblock: bool = False 26 | language: str = "" 27 | 28 | def to_symbols(self) -> tuple[MarkdownSymbol, MarkdownSymbol]: 29 | start = MarkdownSymbol.from_span(self, end=False) 30 | end = MarkdownSymbol.from_span(self, end=True) 31 | return start, end 32 | 33 | 34 | @dataclass 35 | class MarkdownSymbol: 36 | position: int 37 | italic: bool = False 38 | bold: bool = False 39 | code: bool = False 40 | codeblock: bool = False 41 | end: bool = False 42 | language: str = "" 43 | 44 | @property 45 | def symbol(self) -> str: 46 | symbol: list[str] = [] 47 | if self.codeblock: 48 | # Only insert language when opening codeblock 49 | lang = self.language if not self.end else "" 50 | symbol.append(f"```{lang}\n") 51 | # TODO: add support for language in fences (codeblock) 52 | else: 53 | if self.italic: 54 | symbol.append("*") 55 | if self.bold: 56 | symbol.append("**") 57 | if self.code: 58 | symbol.append("`") 59 | s = "".join(symbol) 60 | if self.end: 61 | s = f"{s[::-1]}" 62 | return s 63 | 64 | @classmethod 65 | def from_span(cls, span: MarkdownSpan, *, end: bool = False) -> MarkdownSymbol: 66 | return cls( 67 | position=span.end if end else span.start, 68 | italic=span.italic, 69 | bold=span.bold, 70 | code=span.code, 71 | codeblock=span.codeblock, 72 | end=end, 73 | language=span.language, 74 | ) 75 | 76 | 77 | # Easier than implementing rich comparison methods on MarkdownSymbol 78 | def mdsymbol_cmp(a: MarkdownSymbol, b: MarkdownSymbol) -> int: 79 | if a.position < b.position: 80 | return -1 81 | elif a.position > b.position: 82 | return 1 83 | else: 84 | # code tags cannot have other tags inside them 85 | if a.code and not b.code: 86 | return 1 87 | if b.code and not a.code: 88 | return -1 89 | return 0 90 | 91 | 92 | # TODO: rename `markup_to_markdown` to `markup_as_markdown` 93 | # OR rename `markup_to_plaintext` to `markup_as_plaintext` 94 | # I am partial to `x_to_y`. 95 | 96 | 97 | def markup_to_markdown(s: str) -> str: 98 | """Parses a string that might contain markup formatting and converts it to Markdown. 99 | 100 | This is a very naive implementation that only supports a subset of Rich markup, but it's 101 | good enough for our purposes. 102 | """ 103 | t = Text.from_markup(normalize_spaces(s)) 104 | spans: list[MarkdownSpan] = [] 105 | # Markdown has more limited styles than Rich markup, so we just 106 | # identify the ones we care about and ignore the rest. 107 | for span in t.spans: 108 | new_span = MarkdownSpan(span.start, span.end) 109 | styles = str(span.style).lower().split(" ") 110 | # Code (block) styles ignore other styles 111 | if any(s in CODEBLOCK_STYLES for s in styles): 112 | new_span.codeblock = True 113 | lang = next((s for s in styles if s in CODEBLOCK_LANGS), "") 114 | new_span.language = CODEBLOCK_LANGS.get(lang, "") 115 | elif any(s in CODE_STYLES for s in styles): 116 | new_span.code = True 117 | else: 118 | if "italic" in styles: 119 | new_span.italic = True 120 | if "bold" in styles: 121 | new_span.bold = True 122 | spans.append(new_span) 123 | 124 | # Convert MarkdownSpans to MarkdownSymbols 125 | # Each MarkdownSymbol represents a markdown formatting character along 126 | # with its position in the string. 127 | symbols = list(itertools.chain.from_iterable(sp.to_symbols() for sp in spans)) 128 | symbols = sorted(symbols, key=cmp_to_key(mdsymbol_cmp)) 129 | 130 | # List of characters that make up string 131 | plaintext = list(str(t.plain.strip())) # remove leading and trailing whitespace 132 | offset = 0 133 | for symbol in symbols: 134 | plaintext.insert(symbol.position + offset, symbol.symbol) 135 | offset += 1 136 | 137 | return "".join(plaintext) 138 | 139 | 140 | def normalize_spaces(s: str) -> str: 141 | """Normalizes spaces in a string while keeping newlines intact.""" 142 | split = filter(None, s.split(" ")) 143 | parts: list[str] = [] 144 | for part in split: 145 | if part.endswith("\n"): 146 | parts.append(part) 147 | else: 148 | parts.append(f"{part} ") 149 | return "".join(parts) 150 | 151 | 152 | def markup_as_plain_text(s: str) -> str: 153 | """Renders a string that might contain markup formatting as a plain text string.""" 154 | return Text.from_markup(s).plain 155 | -------------------------------------------------------------------------------- /docs/static/img/plugins/console01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unioslo/zabbix-cli/89a4eac093114b8d39cdc32bd630d40fabe9affc/docs/static/img/plugins/console01.png -------------------------------------------------------------------------------- /docs/static/img/plugins/help_cat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unioslo/zabbix-cli/89a4eac093114b8d39cdc32bd630d40fabe9affc/docs/static/img/plugins/help_cat.png -------------------------------------------------------------------------------- /docs/static/img/plugins/help_command_empty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unioslo/zabbix-cli/89a4eac093114b8d39cdc32bd630d40fabe9affc/docs/static/img/plugins/help_command_empty.png -------------------------------------------------------------------------------- /docs/static/img/plugins/help_empty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unioslo/zabbix-cli/89a4eac093114b8d39cdc32bd630d40fabe9affc/docs/static/img/plugins/help_empty.png -------------------------------------------------------------------------------- /docs/static/img/plugins/render_header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unioslo/zabbix-cli/89a4eac093114b8d39cdc32bd630d40fabe9affc/docs/static/img/plugins/render_header.png -------------------------------------------------------------------------------- /docs/static/img/plugins/render_list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unioslo/zabbix-cli/89a4eac093114b8d39cdc32bd630d40fabe9affc/docs/static/img/plugins/render_list.png -------------------------------------------------------------------------------- /docs/static/img/plugins/render_table.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unioslo/zabbix-cli/89a4eac093114b8d39cdc32bd630d40fabe9affc/docs/static/img/plugins/render_table.png -------------------------------------------------------------------------------- /docs/static/img/plugins/speedscope.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unioslo/zabbix-cli/89a4eac093114b8d39cdc32bd630d40fabe9affc/docs/static/img/plugins/speedscope.png -------------------------------------------------------------------------------- /docs/templates/category.md.j2: -------------------------------------------------------------------------------- 1 | {# Template takes in a list of CommandSummary objects #} 2 | 3 | {% macro render_param(param) -%} 4 | {% if param.is_argument -%} 5 | **`{{ param.human_readable_name }}`** 6 | {% else %} 7 | {% if param.opts | length > 0 -%} 8 | {% for param in param.opts %} **`{{ param }}`**{% if not loop.last %}, {% endif %}{% endfor -%} 9 | {% endif -%} 10 | {% if param.secondary_opts | length > 0 -%} 11 | /{% for param in param.secondary_opts -%} **`{{ param }}`**{% if not loop.last %}, {% endif %}{% endfor -%} 12 | {% endif -%} 13 | {%- if not param.type | lower == "boolean" %} 14 | `{{ param.metavar }}` 15 | {% endif -%} 16 | {% endif %}
17 | 18 | {%- if param.help -%} 19 | {{ param.help_md }} 20 | {%- endif -%} 21 | {%- if param.multiple -%} 22 |
*Separate multiple values with commas 23 | {%- if param.opts%}, or use `{{ param.opts | first }}` multiple times{% endif %}.* 24 | {%- endif -%}
25 | 26 | {#- bandaid to patch in harborapi query usage -#} 27 | {%- if not param.is_argument and param.name == "query" -%} 28 | See [harborapi docs](https://unioslo.github.io/harborapi/usage/methods/read/#query) for more information.
29 | {%- endif -%} 30 | 31 | **Type:** `{{ param.type}}` {% if param.is_flag %}(flag){% endif %}
32 | 33 | {%- if param.type == "choice" -%} 34 | **Choices:** {% for element in param.choices -%}{{ "`" + element + "`" if loop.first else ", `" + element + "`" }}{% endfor %}
35 | {%- endif -%} 36 | 37 | {%- if param.min is not none -%} 38 | **Min:** `{{ param.min }}`
39 | {%- endif -%} 40 | 41 | {%- if param.max is not none -%} 42 | **Max:** `{{ param.max }}`
43 | {%- endif -%} 44 | 45 | {%- if param.default is not none -%} 46 | **Default:** `{{ param.default }}`
47 | {%- endif -%} 48 | 49 | {%- if param.required -%} 50 | **Required:** ✅
51 | {%- endif -%} 52 | {%- endmacro %} 53 | 54 | {% if category | length > 0 %} 55 | # {{ category }} 56 | 57 | {% else %} 58 | 59 | # Top-level commands 60 | 61 | {% endif %} 62 | 63 | {% for command in commands %} 64 | ## {{ command.name }} 65 | 66 | 67 | {% if command.deprecated -%} 68 | !!! warning "Deprecated" 69 | This command is deprecated and will be unsupported in the future. 70 | {% endif -%} 71 | 72 | ``` 73 | {{ command.usage }} 74 | ``` 75 | 76 | {{ command.help_md }} 77 | 78 | {# Only show this section if we have arguments #} 79 | 80 | {% if command.arguments | length > 0 %} 81 | **Arguments** 82 | 83 | {% for param in command.arguments %} 84 | {{ render_param(param) }} 85 | {# End param loop #} 86 | {% endfor %} 87 | 88 | {# End argument listing #} 89 | {% endif %} 90 | 91 | {# Only show this section if we have options #} 92 | {% if command.options | length > 0 %} 93 | 94 | **Options** 95 | 96 | {# Opts. Example (--wizard/--no-wizard) #} 97 | {% for param in command.options %} 98 | {{ render_param(param) }} 99 | {# End param loop #} 100 | {% endfor %} 101 | 102 | {# End params listing #} 103 | {% endif %} 104 | 105 | ---- 106 | 107 | {# End category commands loop #} 108 | {% endfor %} 109 | -------------------------------------------------------------------------------- /docs/templates/command_list.md.j2: -------------------------------------------------------------------------------- 1 | # Commands 2 | 3 | There are currently {{ commandlist | length }} available commands. 4 | 5 | For more information about a specific command, use: 6 | 7 | ``` 8 | zabbix-cli --help 9 | ``` 10 | 11 | See [Commands](../commands/index.md) for usage info of each command. 12 | 13 | 14 | 15 | 16 | ## List of commands 17 | 18 | ``` 19 | {% for command in commandlist -%} 20 | {{ command }} 21 | {% endfor %} 22 | ``` 23 | 24 | {% for category, path in pages.items() -%} 25 | * [{{ category }}]({{ path }}) 26 | {% endfor %} 27 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: zabbix-cli 2 | 3 | repo_url: https://github.com/unioslo/zabbix-cli 4 | repo_name: unioslo/zabbix-cli 5 | edit_uri: edit/master/docs/ 6 | 7 | theme: 8 | name: material 9 | palette: 10 | scheme: slate 11 | primary: blue 12 | accent: orange 13 | language: en 14 | features: 15 | - navigation.tracking 16 | - content.code.annotate 17 | - content.code.copy 18 | - content.tabs.link 19 | - content.action.edit 20 | - toc.follow 21 | - navigation.path 22 | - navigation.top 23 | - navigation.tabs 24 | - navigation.footer 25 | - navigation.sections 26 | - navigation.indexes 27 | 28 | watch: 29 | - zabbix_cli 30 | 31 | plugins: 32 | - autorefs 33 | - search 34 | - glightbox # images 35 | - mkdocs-simple-hooks: 36 | hooks: 37 | # NOTE: we would like to run this on_pre_build, but it causes an infinite 38 | # loop due to generating new files. 39 | # This might be solveable by migrating all these scripts to mkdocs-gen-files 40 | on_startup: docs.scripts.run:main 41 | - literate-nav: # enhanced nav section (enables wildcards) 42 | - include-markdown: 43 | preserve_includer_indent: true 44 | - gen-files: 45 | scripts: 46 | - docs/scripts/gen_ref_pages.py 47 | - macros: 48 | on_error_fail: true 49 | include_dir: docs/data 50 | include_yaml: 51 | - commandlist: docs/data/commandlist.yaml 52 | - formats: docs/data/formats.yaml 53 | - options: docs/data/options.yaml 54 | - config_options: docs/data/config_options.yaml 55 | - mkdocstrings: 56 | enable_inventory: true 57 | handlers: 58 | python: 59 | import: 60 | - https://docs.python.org/3/objects.inv 61 | options: 62 | docstring_style: numpy 63 | members_order: source 64 | docstring_section_style: table 65 | heading_level: 1 66 | show_source: true 67 | show_if_no_docstring: true 68 | show_signature_annotations: true 69 | show_root_heading: true 70 | show_category_heading: true 71 | 72 | markdown_extensions: 73 | - pymdownx.highlight: 74 | anchor_linenums: true 75 | - admonition 76 | - attr_list 77 | - def_list 78 | - footnotes 79 | - md_in_html 80 | - pymdownx.details 81 | - pymdownx.inlinehilite 82 | - pymdownx.snippets 83 | - pymdownx.superfences 84 | - pymdownx.details 85 | - pymdownx.keys 86 | - pymdownx.superfences 87 | - pymdownx.tabbed: 88 | alternate_style: true 89 | - mdx_gh_links: 90 | user: unioslo 91 | repo: zabbix-cli 92 | - toc: 93 | toc_depth: 3 94 | 95 | nav: 96 | - Home: 97 | - index.md 98 | - User Guide: 99 | - Installation: guide/installation.md 100 | - Configuration: guide/configuration.md 101 | - Authentication: guide/authentication.md 102 | - Usage: guide/usage.md 103 | - Logging: guide/logging.md 104 | - Bulk Operations: guide/bulk.md 105 | - Migration: guide/migration.md 106 | - Commands: 107 | - commands/*.md 108 | - Plugins: 109 | - Introduction: plugins/index.md 110 | - plugins/guide.md 111 | - plugins/local-plugins.md 112 | - plugins/external-plugins.md 113 | # - Reference: 114 | # - reference/*.md 115 | # - contributing.md 116 | -------------------------------------------------------------------------------- /resources/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unioslo/zabbix-cli/89a4eac093114b8d39cdc32bd630d40fabe9affc/resources/help.png -------------------------------------------------------------------------------- /resources/host-inventory.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unioslo/zabbix-cli/89a4eac093114b8d39cdc32bd630d40fabe9affc/resources/host-inventory.png -------------------------------------------------------------------------------- /resources/hosts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unioslo/zabbix-cli/89a4eac093114b8d39cdc32bd630d40fabe9affc/resources/hosts.png -------------------------------------------------------------------------------- /resources/open-autocomplete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unioslo/zabbix-cli/89a4eac093114b8d39cdc32bd630d40fabe9affc/resources/open-autocomplete.png -------------------------------------------------------------------------------- /resources/proxies.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unioslo/zabbix-cli/89a4eac093114b8d39cdc32bd630d40fabe9affc/resources/proxies.png -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unioslo/zabbix-cli/89a4eac093114b8d39cdc32bd630d40fabe9affc/tests/__init__.py -------------------------------------------------------------------------------- /tests/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unioslo/zabbix-cli/89a4eac093114b8d39cdc32bd630d40fabe9affc/tests/commands/__init__.py -------------------------------------------------------------------------------- /tests/commands/test_common.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import click 4 | import pytest 5 | import typer 6 | from inline_snapshot import snapshot 7 | from zabbix_cli.app.app import StatefulApp 8 | from zabbix_cli.commands.common.args import CommandParam 9 | 10 | 11 | def test_command_param(ctx: typer.Context) -> None: 12 | param = CommandParam() 13 | assert param.name == "command" 14 | 15 | # No ctx 16 | with pytest.raises(click.exceptions.BadParameter) as exc_info: 17 | param.convert("some-non-existent-command", None, None) 18 | assert exc_info.exconly() == snapshot("click.exceptions.BadParameter: No context.") 19 | 20 | # No value (empty string) 21 | with pytest.raises(click.exceptions.BadParameter) as exc_info: 22 | param.convert("", None, ctx) 23 | assert exc_info.exconly() == snapshot( 24 | "click.exceptions.BadParameter: Missing command." 25 | ) 26 | 27 | # No value (None) 28 | with pytest.raises(click.exceptions.BadParameter) as exc_info: 29 | param.convert(None, None, ctx) # type: ignore 30 | assert exc_info.exconly() == snapshot( 31 | "click.exceptions.BadParameter: Missing command." 32 | ) 33 | 34 | # Command not found 35 | with pytest.raises(click.exceptions.BadParameter) as exc_info: 36 | param.convert("some-non-existent-command", None, None) 37 | assert exc_info.exconly() == snapshot("click.exceptions.BadParameter: No context.") 38 | 39 | 40 | def test_command_param_in_command( 41 | app: StatefulApp, capsys: pytest.CaptureFixture[str] 42 | ) -> None: 43 | @app.command(name="help-command") 44 | def help_command( # type: ignore 45 | ctx: typer.Context, 46 | cmd_arg: click.Command = typer.Argument( 47 | ..., help="The command to get help for." 48 | ), 49 | ) -> str: 50 | return cmd_arg.get_help(ctx) 51 | 52 | @app.command(name="other-command", help="Help for the other command.") 53 | def other_command(ctx: typer.Context) -> None: # type: ignore 54 | pass 55 | 56 | cmd = typer.main.get_command(app) 57 | 58 | with cmd.make_context(None, ["help-command", "other-command"]) as new_ctx: 59 | new_ctx.info_name = "other-command" 60 | cmd.invoke(new_ctx) 61 | captured = capsys.readouterr() 62 | assert captured.err == snapshot("") 63 | # We cannot test the output with snapshot testing, because 64 | # the trim-trailing-whitespace pre-commit hook modifies the 65 | # snapshot output. Not ideal, so we have to test the relevant lines instead. 66 | # 67 | # Also, terminal styling is broken when testing outside of a terminal. 68 | # Thus, this minimal test. 69 | assert "other-command help-command [OPTIONS]" in captured.out 70 | -------------------------------------------------------------------------------- /tests/commands/test_macro.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from zabbix_cli.commands.macro import fmt_macro_name 5 | from zabbix_cli.exceptions import ZabbixCLIError 6 | 7 | MARK_FAIL = pytest.mark.xfail(raises=ZabbixCLIError, strict=True) 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "name, expected", 12 | [ 13 | pytest.param("my_macro", "{$MY_MACRO}"), 14 | pytest.param("MY_MACRO", "{$MY_MACRO}"), 15 | pytest.param("mY_maCrO", "{$MY_MACRO}"), 16 | pytest.param("foo123", "{$FOO123}"), 17 | pytest.param(" ", "", marks=MARK_FAIL), 18 | pytest.param("", "", marks=MARK_FAIL), 19 | pytest.param("{$}", "", marks=MARK_FAIL), 20 | ], 21 | ) 22 | def test_fmt_macro_name(name: str, expected: str) -> None: 23 | assert fmt_macro_name(name) == expected 24 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Generator 4 | from collections.abc import Iterator 5 | from pathlib import Path 6 | from typing import Any 7 | 8 | import pytest 9 | import typer 10 | from packaging.version import Version 11 | from typer.testing import CliRunner 12 | from zabbix_cli.app import StatefulApp 13 | from zabbix_cli.config.model import Config 14 | from zabbix_cli.main import app 15 | from zabbix_cli.pyzabbix.client import ZabbixAPI 16 | from zabbix_cli.state import State 17 | from zabbix_cli.state import get_state 18 | 19 | runner = CliRunner() 20 | 21 | 22 | @pytest.fixture(name="app") 23 | def _app() -> Iterator[StatefulApp]: 24 | yield app 25 | 26 | 27 | @pytest.fixture 28 | def ctx(app: StatefulApp) -> typer.Context: 29 | """Create context for the main command.""" 30 | # Use the CliRunner to invoke a command and capture the context 31 | obj = {} 32 | with runner.isolated_filesystem(): 33 | 34 | @app.callback(invoke_without_command=True) 35 | def callback(ctx: typer.Context): 36 | obj["ctx"] = ctx # Capture the context in a non-local object 37 | 38 | runner.invoke(app, [], obj=obj) 39 | return obj["ctx"] 40 | 41 | 42 | DATA_DIR = Path(__file__).parent / "data" 43 | 44 | # Read sample configs once per test run to avoid too much I/O 45 | TOML_CONFIG = DATA_DIR / "zabbix-cli.toml" 46 | TOML_CONFIG_STR = TOML_CONFIG.read_text() 47 | 48 | CONF_CONFIG = DATA_DIR / "zabbix-cli.conf" 49 | CONF_CONFIG_STR = CONF_CONFIG.read_text() 50 | 51 | 52 | @pytest.fixture() 53 | def data_dir() -> Iterator[Path]: 54 | yield Path(__file__).parent / "data" 55 | 56 | 57 | @pytest.fixture() 58 | def config_path(tmp_path: Path) -> Iterator[Path]: 59 | config_copy = tmp_path / "zabbix-cli.toml" 60 | config_copy.write_text(TOML_CONFIG_STR) 61 | yield config_copy 62 | 63 | 64 | @pytest.fixture() 65 | def legacy_config_path(tmp_path: Path) -> Iterator[Path]: 66 | config_copy = tmp_path / "zabbix-cli.conf" 67 | config_copy.write_text(CONF_CONFIG_STR) 68 | yield config_copy 69 | 70 | 71 | @pytest.fixture(name="state") 72 | def state(config: Config, zabbix_client: ZabbixAPI) -> Iterator[State]: 73 | """Return a fresh State object with a config and client. 74 | 75 | The client is not logged in to the Zabbix API. 76 | 77 | Modifies the State singleton to ensure a fresh state is returned 78 | each time. 79 | """ 80 | State._instance = None # pyright: ignore[reportPrivateUsage] 81 | state = get_state() 82 | state.config = config 83 | state.client = zabbix_client 84 | yield state 85 | # reset after test 86 | State._instance = None # pyright: ignore[reportPrivateUsage] 87 | 88 | 89 | @pytest.fixture(name="config") 90 | def config(tmp_path: Path) -> Iterator[Config]: 91 | """Return a sample config.""" 92 | conf = Config.sample_config() 93 | # Set up logging for the test environment 94 | log_file = tmp_path / "zabbix-cli.log" 95 | conf.logging.log_file = log_file 96 | conf.logging.log_level = "DEBUG" # we want to see all logs 97 | yield conf 98 | 99 | 100 | @pytest.fixture(name="zabbix_client") 101 | def zabbix_client() -> Iterator[ZabbixAPI]: 102 | config = Config.sample_config() 103 | client = ZabbixAPI.from_config(config) 104 | yield client 105 | 106 | 107 | @pytest.fixture(name="zabbix_client_mock_version") 108 | def zabbix_client_mock_version( 109 | zabbix_client: ZabbixAPI, monkeypatch: pytest.MonkeyPatch 110 | ) -> Iterator[ZabbixAPI]: 111 | monkeypatch.setattr(zabbix_client, "api_version", lambda: Version("7.0.0")) 112 | yield zabbix_client 113 | 114 | 115 | @pytest.fixture(name="force_color") 116 | def force_color() -> Generator[Any, Any, Any]: 117 | import os 118 | 119 | os.environ["FORCE_COLOR"] = "1" 120 | yield 121 | os.environ.pop("FORCE_COLOR", None) 122 | 123 | 124 | @pytest.fixture(name="no_color") 125 | def no_color() -> Generator[Any, Any, Any]: 126 | """Disable color in a test. Takes precedence over force_color.""" 127 | import os 128 | 129 | os.environ.pop("FORCE_COLOR", None) 130 | os.environ["NO_COLOR"] = "1" 131 | yield 132 | os.environ.pop("NO_COLOR", None) 133 | -------------------------------------------------------------------------------- /tests/data/zabbix-cli.conf: -------------------------------------------------------------------------------- 1 | ; 2 | ; Authors: 3 | ; rafael@e-mc2.net / https://e-mc2.net/ 4 | ; 5 | ; Copyright (c) 2014-2017 USIT-University of Oslo 6 | ; 7 | ; This file is part of Zabbix-CLI 8 | ; https://github.com/usit-gd/zabbix-cli 9 | ; 10 | ; Zabbix-cli is free software: you can redistribute it and/or modify 11 | ; it under the terms of the GNU General Public License as published by 12 | ; the Free Software Foundation, either version 3 of the License, or 13 | ; (at your option) any later version. 14 | ; 15 | ; Zabbix-cli is distributed in the hope that it will be useful, 16 | ; but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | ; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | ; GNU General Public License for more details. 19 | ; 20 | ; You should have received a copy of the GNU General Public License 21 | ; along with Zabbix-CLI. If not, see . 22 | ; 23 | ; Zabbix-cli uses a multilevel configuration approach to implement a 24 | ; flexible configuration system. 25 | ; 26 | ; Zabbix-cli will check these configuration files if they exist and 27 | ; will merge the information to get the configuration to use. 28 | ; 29 | ; 1. /usr/share/zabbix-cli/zabbix-cli.fixed.conf 30 | ; 2. /etc/zabbix-cli/zabbix-cli.fixed.conf 31 | ; 3. Configuration file defined with the parameter -c / --config when executing zabbix-cli 32 | ; 4. $HOME/.zabbix-cli/zabbix-cli.conf 33 | ; 5. /etc/zabbix-cli/zabbix-cli.conf 34 | ; 6. /usr/share/zabbix-cli/zabbix-cli.conf 35 | ; 36 | ; Check the documentation for full details on how to configurate the 37 | ; system. 38 | ; 39 | 40 | ; ###################### 41 | ; Zabbix API section 42 | ; ###################### 43 | [zabbix_api] 44 | 45 | ; Zabbix API URL without /api_jsonrpc.php 46 | ; NB: http does not work, and does so in unexpected ways (with json parse error) 47 | zabbix_api_url=http://zabbix.example.net/zabbix 48 | 49 | ; Configure certificate verification for the API server. 50 | ; ON/OFF is passed as verify=True/False to the requests library. 51 | ; Everything else is passed as is and assumed to be a file path to a CA bundle. 52 | ;cert_verify=ON 53 | 54 | ; ############################ 55 | ; zabbix_config section 56 | ; ############################ 57 | [zabbix_config] 58 | 59 | ; system ID. This ID will be use in the zabbix-cli prompt 60 | ; to identify the system we are connected to. 61 | ; Default: zabbix-ID 62 | system_id=Test 63 | 64 | ; Default hostgroup. We need a default hostgroup when 65 | ; creating a host. 66 | ; Default: All-hosts 67 | default_hostgroup=All-hosts 68 | 69 | ; Default admin usergroup/s(root). We need a default Admin usergroup when 70 | ; creating a hostgroup to get the right privileges in place. 71 | ; Default: Zabbix-root 72 | default_admin_usergroup=Zabbix-root 73 | 74 | ; Default usergroup. We need a default usergroup/s when 75 | ; creating a user to get the default access in place. 76 | ; Default: All-users 77 | default_create_user_usergroup=All-users 78 | 79 | ; Default notification usergroup. We need a default notification usergroup when 80 | ; creating a notification user to get the default access in place. 81 | ; Default: All-notification-users 82 | default_notification_users_usergroup=All-notification-users 83 | 84 | ; Default directory to save exported configuration files. 85 | ; Default: $HOME/zabbix_exports 86 | ; default_directory_exports= 87 | 88 | ; Default export format. 89 | ; default_export_format: JSON, XML 90 | ; Default: XML 91 | ; default_export_format=XML 92 | ; 93 | ; We deactivate this parameter until 94 | ; https://support.zabbix.com/browse/ZBX-10607 gets fixed. 95 | ; We use XML as the export format. 96 | 97 | ; Include timestamp in export filenames 98 | ; include_timestamp_export_filename: ON, OFF 99 | ; Default: ON 100 | include_timestamp_export_filename=ON 101 | 102 | ; Use colors when showing alarm information 103 | ; use_colors: ON, OFF 104 | ; Default: ON 105 | use_colors=ON 106 | 107 | ; Generate a file $HOME/.zabbix-cli_auth_token with the API-token 108 | ; delivered by the API after the last authentication of a user. If 109 | ; this file exists and the token is still active, the user would not 110 | ; have to write the username/password to login 111 | ; use_auth_token_file: ON,OFF 112 | ; Default: OFF 113 | use_auth_token_file=OFF 114 | 115 | ; Use paging when printing any output. 116 | ; If active, Zabbix-CLI will use the pager defined in the environment 117 | ; variable PAGER. If this variable is not defined, it will try to use 118 | ; the POSIX command 'more' 119 | ; 120 | ; use_paging: ON, OFF 121 | ; Default:OFF 122 | use_paging=OFF 123 | 124 | ; ###################### 125 | ; Logging section 126 | ; ###################### 127 | [logging] 128 | 129 | ; The user running zabbix-cli has to have RW access to log_file if 130 | ; logging is active 131 | ; Logging: ON, OFF 132 | ; Default: OFF 133 | logging=OFF 134 | 135 | ; Log level: DEBUG, INFO, WARN, ERROR, CRITICAL 136 | ; Default: ERROR 137 | log_level=INFO 138 | 139 | ; Log file used by zabbix-cli 140 | ; Default: /var/log/zabbix-cli/zabbix-cli.log 141 | ;log_file=/var/log/zabbix-cli/zabbix-cli.log 142 | -------------------------------------------------------------------------------- /tests/data/zabbix-cli.toml: -------------------------------------------------------------------------------- 1 | [api] 2 | url = "https://zabbix.example.com" 3 | username = "Admin" 4 | password = "" 5 | verify_ssl = true 6 | auth_token = "" 7 | 8 | [app] 9 | default_hostgroups = ["All-hosts"] 10 | default_admin_usergroups = [] 11 | default_create_user_usergroups = [] 12 | default_notification_users_usergroups = ["All-notification-users"] 13 | export_directory = "/path/to/exports" 14 | export_format = "json" 15 | export_timestamps = false 16 | use_session_file = true 17 | auth_token_file = "/path/to/auth_token_file" 18 | auth_file = "/path/to/auth_file" 19 | history = true 20 | history_file = "/path/to/history_file" 21 | allow_insecure_auth_file = true 22 | legacy_json_format = false 23 | 24 | [app.commands.create_host] 25 | create_interface = true 26 | 27 | [app.output] 28 | format = "table" 29 | color = true 30 | paging = false 31 | 32 | 33 | [logging] 34 | enabled = true 35 | log_level = "ERROR" 36 | log_file = "/path/to/log_file" 37 | -------------------------------------------------------------------------------- /tests/pyzabbix/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unioslo/zabbix-cli/89a4eac093114b8d39cdc32bd630d40fabe9affc/tests/pyzabbix/__init__.py -------------------------------------------------------------------------------- /tests/pyzabbix/test_enums.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from zabbix_cli.pyzabbix.enums import AckStatus 5 | from zabbix_cli.pyzabbix.enums import ActiveInterface 6 | from zabbix_cli.pyzabbix.enums import APIStr 7 | from zabbix_cli.pyzabbix.enums import APIStrEnum 8 | from zabbix_cli.pyzabbix.enums import DataCollectionMode 9 | from zabbix_cli.pyzabbix.enums import EventStatus 10 | from zabbix_cli.pyzabbix.enums import ExportFormat 11 | from zabbix_cli.pyzabbix.enums import GUIAccess 12 | from zabbix_cli.pyzabbix.enums import HostgroupFlag 13 | from zabbix_cli.pyzabbix.enums import HostgroupType 14 | from zabbix_cli.pyzabbix.enums import InterfaceConnectionMode 15 | from zabbix_cli.pyzabbix.enums import InterfaceType 16 | from zabbix_cli.pyzabbix.enums import InventoryMode 17 | from zabbix_cli.pyzabbix.enums import ItemType 18 | from zabbix_cli.pyzabbix.enums import MacroType 19 | from zabbix_cli.pyzabbix.enums import MaintenanceStatus 20 | from zabbix_cli.pyzabbix.enums import MaintenanceType 21 | from zabbix_cli.pyzabbix.enums import MaintenanceWeekType 22 | from zabbix_cli.pyzabbix.enums import MonitoredBy 23 | from zabbix_cli.pyzabbix.enums import MonitoringStatus 24 | from zabbix_cli.pyzabbix.enums import ProxyCompatibility 25 | from zabbix_cli.pyzabbix.enums import ProxyGroupState 26 | from zabbix_cli.pyzabbix.enums import ProxyMode 27 | from zabbix_cli.pyzabbix.enums import ProxyModePre70 28 | from zabbix_cli.pyzabbix.enums import SNMPAuthProtocol 29 | from zabbix_cli.pyzabbix.enums import SNMPPrivProtocol 30 | from zabbix_cli.pyzabbix.enums import SNMPSecurityLevel 31 | from zabbix_cli.pyzabbix.enums import TriggerPriority 32 | from zabbix_cli.pyzabbix.enums import UsergroupPermission 33 | from zabbix_cli.pyzabbix.enums import UserRole 34 | from zabbix_cli.pyzabbix.enums import ValueType 35 | 36 | APISTR_ENUMS = [ 37 | AckStatus, 38 | ActiveInterface, 39 | DataCollectionMode, 40 | EventStatus, 41 | GUIAccess, 42 | HostgroupFlag, 43 | HostgroupType, 44 | InterfaceConnectionMode, 45 | InterfaceType, 46 | InventoryMode, 47 | ItemType, 48 | MacroType, 49 | MaintenanceStatus, 50 | MaintenanceType, 51 | MaintenanceWeekType, 52 | MonitoredBy, 53 | MonitoringStatus, 54 | ProxyCompatibility, 55 | ProxyGroupState, 56 | ProxyMode, 57 | ProxyModePre70, 58 | SNMPSecurityLevel, 59 | SNMPAuthProtocol, 60 | SNMPPrivProtocol, 61 | TriggerPriority, 62 | UsergroupPermission, 63 | UserRole, 64 | ValueType, 65 | ] 66 | 67 | 68 | @pytest.mark.parametrize("enum", APISTR_ENUMS) 69 | def test_apistrenum(enum: type[APIStrEnum]) -> None: 70 | assert enum.__members__ 71 | members = list(enum) 72 | assert members 73 | for member in members: 74 | # Narrow down type 75 | assert isinstance(member, enum) 76 | assert isinstance(member.value, str) 77 | assert isinstance(member.value, APIStr) 78 | 79 | # Methods 80 | assert member.as_api_value() is not None 81 | assert member.__choice_name__ is not None 82 | assert member.__fmt_name__() # non-empty string 83 | 84 | # Test instantiation 85 | assert enum(member) == member 86 | assert enum(member.value) == member 87 | # NOTE: to support multiple versions of the Zabbix API, some enums 88 | # have multiple members with the same API value, and we cannot blindly 89 | # test instantiation with the API value for those specific enums. 90 | # To not overcomplicate things, we just skip that test for the affected members 91 | if member in (SNMPPrivProtocol.AES, SNMPPrivProtocol.AES128): 92 | continue 93 | assert enum(member.as_api_value()) == member 94 | assert enum(member.value.api_value) == member 95 | 96 | # Test string_from_value 97 | for value in [member.as_api_value(), member.value]: 98 | s = enum.string_from_value(value) 99 | if member.name != "UNKNOWN": 100 | assert "Unknown" not in s, f"{value} can't be converted to string" 101 | assert s == member.as_status() 102 | 103 | 104 | def test_interfacetype() -> None: 105 | # We already test normal behavior in test_apistrenum, check special behavior here 106 | for member in InterfaceType: 107 | assert member.value.metadata 108 | assert member.get_port() 109 | assert InterfaceType.AGENT.get_port() == "10050" 110 | assert InterfaceType.SNMP.get_port() == "161" 111 | assert InterfaceType.IPMI.get_port() == "623" 112 | assert InterfaceType.JMX.get_port() == "12345" 113 | 114 | 115 | def test_exportformat() -> None: 116 | assert ExportFormat.PHP not in ExportFormat.get_importables() 117 | assert ExportFormat("json") == ExportFormat("JSON") 118 | assert ExportFormat("xml") == ExportFormat("XML") 119 | assert ExportFormat("yaml") == ExportFormat("YAML") 120 | assert ExportFormat.JSON in ExportFormat.get_importables() 121 | assert ExportFormat.XML in ExportFormat.get_importables() 122 | assert ExportFormat.YAML in ExportFormat.get_importables() 123 | -------------------------------------------------------------------------------- /tests/pyzabbix/test_types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | 5 | import pytest 6 | from zabbix_cli.pyzabbix.enums import ProxyGroupState 7 | from zabbix_cli.pyzabbix.types import CreateHostInterfaceDetails 8 | from zabbix_cli.pyzabbix.types import DictModel 9 | from zabbix_cli.pyzabbix.types import Event 10 | from zabbix_cli.pyzabbix.types import GlobalMacro 11 | from zabbix_cli.pyzabbix.types import Host 12 | from zabbix_cli.pyzabbix.types import HostGroup 13 | from zabbix_cli.pyzabbix.types import HostInterface 14 | from zabbix_cli.pyzabbix.types import Image 15 | from zabbix_cli.pyzabbix.types import ImportRules 16 | from zabbix_cli.pyzabbix.types import Item 17 | from zabbix_cli.pyzabbix.types import Macro 18 | from zabbix_cli.pyzabbix.types import MacroBase 19 | from zabbix_cli.pyzabbix.types import Maintenance 20 | from zabbix_cli.pyzabbix.types import Map 21 | from zabbix_cli.pyzabbix.types import MediaType 22 | from zabbix_cli.pyzabbix.types import ProblemTag 23 | from zabbix_cli.pyzabbix.types import Proxy 24 | from zabbix_cli.pyzabbix.types import ProxyGroup 25 | from zabbix_cli.pyzabbix.types import Role 26 | from zabbix_cli.pyzabbix.types import Template 27 | from zabbix_cli.pyzabbix.types import TemplateGroup 28 | from zabbix_cli.pyzabbix.types import TimePeriod 29 | from zabbix_cli.pyzabbix.types import Trigger 30 | from zabbix_cli.pyzabbix.types import UpdateHostInterfaceDetails 31 | from zabbix_cli.pyzabbix.types import User 32 | from zabbix_cli.pyzabbix.types import Usergroup 33 | from zabbix_cli.pyzabbix.types import UserMedia 34 | from zabbix_cli.pyzabbix.types import ZabbixAPIBaseModel 35 | from zabbix_cli.pyzabbix.types import ZabbixRight 36 | 37 | 38 | @pytest.mark.parametrize( 39 | "model", 40 | [ 41 | # TODO: replace with hypothesis tests when Pydantic v2 support is available 42 | # Test in order of definition in types.py 43 | ZabbixRight(permission=2, id="str"), 44 | User(userid="123", username="test"), 45 | Usergroup(name="test", usrgrpid="123", gui_access=0, users_status=0), 46 | Template(templateid="123", host="test"), 47 | TemplateGroup(groupid="123", name="test", uuid="test123"), 48 | HostGroup(name="test", groupid="123"), 49 | DictModel(), 50 | Host(hostid="123"), 51 | HostInterface( 52 | type=1, main=1, ip="127.0.0.1", dns="", port="10050", useip=1, bulk=1 53 | ), 54 | CreateHostInterfaceDetails(version=2), 55 | UpdateHostInterfaceDetails(), 56 | Proxy(proxyid="123", name="test", address="127.0.0.1"), 57 | ProxyGroup( 58 | proxy_groupid="123", 59 | name="test", 60 | description="yeah", 61 | failover_delay="60", 62 | min_online=1, 63 | state=ProxyGroupState.ONLINE, 64 | ), 65 | MacroBase(macro="foo", value="bar", type=0, description="baz"), 66 | Macro( 67 | hostid="123", 68 | hostmacroid="1234", 69 | macro="foo", 70 | value="bar", 71 | type=0, 72 | description="baz", 73 | ), 74 | GlobalMacro( 75 | globalmacroid="123g", macro="foo", value="bar", type=0, description="baz" 76 | ), 77 | Item(itemid="123"), 78 | Role(roleid="123", name="test", type=1, readonly=0), 79 | MediaType(mediatypeid="123", name="test", type=0), 80 | UserMedia(mediatypeid="123", sendto="foo@example.com"), 81 | TimePeriod(period=123, timeperiod_type=2), 82 | ProblemTag(tag="foo", operator=2, value="bar"), 83 | Maintenance(maintenanceid="123", name="test"), 84 | Event( 85 | eventid="source", 86 | object=1, 87 | objectid="123", 88 | source=2, 89 | acknowledged=0, 90 | clock=datetime.now(), 91 | name="test", 92 | severity=2, 93 | ), 94 | Trigger(triggerid="123"), 95 | Image(imageid="123", name="test", imagetype=1), 96 | Map(sysmapid="123", name="test", width=100, height=100), 97 | ImportRules.get(), 98 | ], 99 | ) 100 | def test_model_dump(model: ZabbixAPIBaseModel) -> None: 101 | """Test that the model can be dumped to JSON.""" 102 | try: 103 | model.model_dump_json() 104 | except Exception as e: 105 | pytest.fail(f"Failed to dump model {model} to JSON: {e}") 106 | 107 | try: 108 | model.model_dump_api() 109 | except Exception as e: 110 | pytest.fail(f"Failed to dump model {model} to API-compatible dict: {e}") 111 | 112 | try: 113 | model.model_dump() 114 | except Exception as e: 115 | pytest.fail(f"Failed to dump model {model} to dict: {e}") 116 | -------------------------------------------------------------------------------- /tests/test_app.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from inline_snapshot import snapshot 4 | from pytest import LogCaptureFixture 5 | from zabbix_cli.app.app import StatefulApp 6 | from zabbix_cli.config.model import PluginConfig 7 | from zabbix_cli.state import State 8 | 9 | 10 | def test_get_plugin_config( 11 | app: StatefulApp, state: State, caplog: LogCaptureFixture 12 | ) -> None: 13 | """Test that we can get a plugin's configuration.""" 14 | # Add a plugin configuration 15 | state.config.plugins.root = { 16 | # From module specification 17 | "my_commands": PluginConfig( 18 | module="path.to.my_commands", 19 | ), 20 | # From path 21 | "my_commands2": PluginConfig( 22 | module="path/to/my_commands2.py", 23 | ), 24 | # From package with entrypoint 25 | "my_commands3": PluginConfig(), 26 | } 27 | 28 | # With name 29 | config = app.get_plugin_config("my_commands") 30 | assert config.module == "path.to.my_commands" 31 | 32 | # Missing config returns empty config 33 | config = app.get_plugin_config("missing") 34 | assert config.module == "" 35 | assert caplog.records[-1].message == snapshot( 36 | "Plugin 'missing' not found in configuration" 37 | ) 38 | -------------------------------------------------------------------------------- /tests/test_compat.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from packaging.version import Version 5 | from zabbix_cli.pyzabbix import compat 6 | 7 | 8 | def test_packaging_version_release_sanity(): 9 | """Ensures that the `Version.release` tuple is in the correct format and 10 | supports users running pre-release versions of Zabbix. 11 | """ 12 | assert Version("7.0.0").release == (7, 0, 0) 13 | # Test that all types of pre-releases evaluate to the same release tuple 14 | for pr in ["a1", "b1", "rc1", "alpha1", "beta1"]: 15 | assert Version(f"7.0.0{pr}").release == (7, 0, 0) 16 | assert Version(f"7.0.0{pr}").release >= (7, 0, 0) 17 | assert Version(f"7.0.0{pr}").release <= (7, 0, 0) 18 | 19 | 20 | @pytest.mark.parametrize( 21 | "version, expect", 22 | [ 23 | # TODO (pederhan): decide on a set of versions we test against 24 | # instead of coming up with them on the fly, such as here. 25 | # Do we test against only major versions or minor versions as well? 26 | (Version("7.0.0"), "proxyid"), 27 | (Version("6.0.0"), "proxy_hostid"), 28 | (Version("5.0.0"), "proxy_hostid"), 29 | (Version("3.0.0"), "proxy_hostid"), 30 | (Version("2.0.0"), "proxy_hostid"), 31 | (Version("1.0.0"), "proxy_hostid"), 32 | ], 33 | ) 34 | def test_host_proxyid(version: Version, expect: str): 35 | assert compat.host_proxyid(version) == expect 36 | 37 | 38 | @pytest.mark.parametrize( 39 | "version, expect", 40 | [ 41 | (Version("7.0.0"), "username"), 42 | (Version("6.0.0"), "username"), 43 | (Version("6.2.0"), "username"), 44 | (Version("6.4.0"), "username"), 45 | (Version("5.4.0"), "username"), 46 | (Version("5.4.1"), "username"), 47 | (Version("5.3.9"), "user"), 48 | (Version("5.2.0"), "user"), 49 | (Version("5.2"), "user"), 50 | (Version("5.0"), "user"), 51 | (Version("4.0"), "user"), 52 | (Version("2.0"), "user"), 53 | ], 54 | ) 55 | def test_login_user_name(version: Version, expect: str): 56 | assert compat.login_user_name(version) == expect 57 | 58 | 59 | @pytest.mark.parametrize( 60 | "version, expect", 61 | [ 62 | (Version("7.0.0"), "name"), 63 | (Version("6.0.0"), "host"), 64 | (Version("5.0.0"), "host"), 65 | (Version("3.0.0"), "host"), 66 | (Version("2.0.0"), "host"), 67 | (Version("1.0.0"), "host"), 68 | ], 69 | ) 70 | def test_proxy_name(version: Version, expect: str): 71 | assert compat.proxy_name(version) == expect 72 | 73 | 74 | @pytest.mark.parametrize( 75 | "version, expect", 76 | [ 77 | (Version("7.0.0"), "username"), 78 | (Version("6.4.0"), "username"), 79 | (Version("6.0.0"), "username"), 80 | # NOTE: special case here where we use "alias" instead of "username" 81 | # even though it was deprecated in 5.4.0 (matches historical zabbix_cli behavior) 82 | (Version("5.4.0"), "alias"), 83 | (Version("5.0.0"), "alias"), 84 | (Version("3.0.0"), "alias"), 85 | (Version("2.0.0"), "alias"), 86 | (Version("1.0.0"), "alias"), 87 | ], 88 | ) 89 | def test_user_name(version: Version, expect: str): 90 | assert compat.user_name(version) == expect 91 | -------------------------------------------------------------------------------- /tests/test_docs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unioslo/zabbix-cli/89a4eac093114b8d39cdc32bd630d40fabe9affc/tests/test_docs/__init__.py -------------------------------------------------------------------------------- /tests/test_exceptions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from inline_snapshot import snapshot 5 | from zabbix_cli.exceptions import ZabbixAPIException 6 | from zabbix_cli.exceptions import ZabbixAPIRequestError 7 | from zabbix_cli.exceptions import ZabbixCLIError 8 | from zabbix_cli.exceptions import get_cause_args 9 | from zabbix_cli.pyzabbix.types import ZabbixAPIError 10 | from zabbix_cli.pyzabbix.types import ZabbixAPIResponse 11 | 12 | 13 | @pytest.mark.parametrize( 14 | "outer_t", [TypeError, ValueError, ZabbixCLIError, ZabbixAPIException] 15 | ) 16 | def test_get_cause_args(outer_t: type[Exception]) -> None: 17 | try: 18 | try: 19 | try: 20 | raise ZabbixAPIException("foo!") 21 | except ZabbixAPIException as e: 22 | raise TypeError("foo", "bar") from e 23 | except TypeError as e: 24 | raise outer_t("outer") from e 25 | except outer_t as e: 26 | args = get_cause_args(e) 27 | assert args == snapshot(["outer", "foo", "bar", "foo!"]) 28 | 29 | 30 | def test_get_cause_args_no_cause() -> None: 31 | e = ZabbixAPIException("foo!") 32 | args = get_cause_args(e) 33 | assert args == snapshot(["foo!"]) 34 | 35 | 36 | def test_get_cause_args_with_api_response() -> None: 37 | api_resp = ZabbixAPIResponse( 38 | jsonrpc="2.0", 39 | result=None, 40 | id=1, 41 | error=ZabbixAPIError(code=-123, message="Some error"), 42 | ) 43 | e = ZabbixAPIRequestError("foo!", api_response=api_resp) 44 | args = get_cause_args(e) 45 | assert args == snapshot(["foo!", "(-123) Some error"]) 46 | 47 | 48 | def test_get_cause_args_with_api_response_with_data() -> None: 49 | """Get the cause args from an exception with an API response with data.""" 50 | api_resp = ZabbixAPIResponse( 51 | jsonrpc="2.0", 52 | result=None, 53 | id=1, 54 | error=ZabbixAPIError(code=-123, message="Some error", data='{"foo": 42}'), 55 | ) 56 | e = ZabbixAPIRequestError("foo!", api_response=api_resp) 57 | args = get_cause_args(e) 58 | assert args == snapshot(["foo!", '(-123) Some error {"foo": 42}']) 59 | -------------------------------------------------------------------------------- /tests/test_logging.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import unittest 5 | 6 | import zabbix_cli.logs 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class CollectHandler(logging.Handler): 12 | def __init__(self): 13 | super().__init__(logging.NOTSET) 14 | self.records = [] 15 | 16 | def emit(self, record): 17 | print("emit", repr(record)) 18 | self.records.append(record) 19 | 20 | 21 | class TestSafeFormatting(unittest.TestCase): 22 | def _make_log_record(self, msg, *args): 23 | # locals for readability 24 | record_logger = "example-logger-name" 25 | record_level = logging.ERROR 26 | record_pathname = __file__ 27 | record_lineno = 1 28 | record_exc_info = None 29 | return logging.LogRecord( 30 | record_logger, 31 | record_level, 32 | record_pathname, 33 | record_lineno, 34 | msg, 35 | args, 36 | record_exc_info, 37 | ) 38 | 39 | def _make_safe_record(self, msg, *args): 40 | return zabbix_cli.logs.SafeRecord(self._make_log_record(msg, *args)) 41 | 42 | def test_safe_record(self): 43 | self._make_safe_record("foo") 44 | self.assertTrue(True) # reached 45 | 46 | def test_safe_record_basic_message(self): 47 | message = "foo" 48 | record = self._make_safe_record(message) 49 | self.assertEqual(message, record.getMessage()) 50 | 51 | def test_safe_record_formatted_message(self): 52 | expect = "foo 01 02" 53 | record = self._make_safe_record("foo %02d %02d", 1, 2) 54 | self.assertEqual(expect, record.getMessage()) 55 | 56 | def test_safe_record_attr(self): 57 | msg = ("foo",) 58 | args = (1, 2) 59 | record = self._make_safe_record(msg, *args) 60 | self.assertEqual(msg, record.msg) 61 | self.assertEqual(args, record.args) 62 | 63 | def test_safe_record_missing_dict(self): 64 | fmt = "%(msg)s-%(this_probably_does_not_exist)s" 65 | record = self._make_safe_record("foo") 66 | expect = f"foo-{None}" 67 | result = fmt % record.__dict__ 68 | self.assertEqual(expect, result) 69 | 70 | def test_safe_formatter(self): 71 | fmt = "%(name)s - %(something)s - %(msg)s" 72 | formatter = zabbix_cli.logs.SafeFormatter(fmt) 73 | record = self._make_log_record("foo") 74 | 75 | expect = fmt % {"name": record.name, "something": None, "msg": record.msg} 76 | self.assertEqual(expect, formatter.format(record)) 77 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | import pytest 6 | from inline_snapshot import snapshot 7 | from pydantic import BaseModel 8 | from pydantic import Field 9 | from pytest import LogCaptureFixture 10 | from zabbix_cli.models import MetaKey 11 | from zabbix_cli.models import TableRenderable 12 | 13 | 14 | @pytest.mark.parametrize( 15 | "header, expect", 16 | [ 17 | pytest.param(None, "Foo", id="None is default"), 18 | pytest.param("", "Foo", id="Empty string is default"), 19 | ("Foo Header", "Foo Header"), 20 | ], 21 | ) 22 | def test_table_renderable_metakey_header(header: str, expect: str) -> None: 23 | class TestTableRenderable(TableRenderable): 24 | foo: str = Field(..., json_schema_extra={MetaKey.HEADER: header}) 25 | 26 | t = TestTableRenderable(foo="bar") 27 | assert t.__cols__() == [expect] 28 | assert t.__rows__() == [["bar"]] 29 | assert t.__cols_rows__() == ([expect], [["bar"]]) 30 | 31 | 32 | @pytest.mark.parametrize( 33 | "content, join_char, expect", 34 | [ 35 | (["a", "b", "c"], ",", ["a,b,c"]), 36 | (["a", "b", "c"], "|", ["a|b|c"]), 37 | (["a", "b", "c"], " ", ["a b c"]), 38 | (["a", "b", "c"], "", ["abc"]), 39 | # Test empty list 40 | ([], ",", [""]), 41 | ([], "|", [""]), 42 | ([], " ", [""]), 43 | ([], "", [""]), 44 | ], 45 | ) 46 | def test_table_renderable_metakey_join_char( 47 | content: list[str], join_char: str, expect: str 48 | ) -> None: 49 | class TestTableRenderable(TableRenderable): 50 | foo: list[str] = Field(..., json_schema_extra={MetaKey.JOIN_CHAR: join_char}) 51 | 52 | t = TestTableRenderable(foo=content) 53 | assert t.__rows__() == [expect] 54 | 55 | 56 | def test_all_metakeys() -> None: 57 | class TestTableRenderable(TableRenderable): 58 | foo: list[str] = Field( 59 | ..., 60 | json_schema_extra={MetaKey.JOIN_CHAR: "|", MetaKey.HEADER: "Foo Header"}, 61 | ) 62 | 63 | t = TestTableRenderable(foo=["foo", "bar"]) 64 | assert t.__cols__() == ["Foo Header"] 65 | assert t.__rows__() == [["foo|bar"]] 66 | assert t.__cols_rows__() == (["Foo Header"], [["foo|bar"]]) 67 | 68 | 69 | def test_rows_with_unknown_base_model(caplog: LogCaptureFixture) -> None: 70 | """Test that we log when we try to render a BaseModel 71 | instance that does not inherit from TableRenderable. 72 | """ 73 | 74 | class FooModel(BaseModel): 75 | foo: str 76 | bar: int 77 | baz: float 78 | qux: list[str] 79 | 80 | class TestTableRenderable(TableRenderable): 81 | foo: FooModel 82 | 83 | t = TestTableRenderable(foo=FooModel(foo="foo", bar=1, baz=1.0, qux=["a", "b"])) 84 | 85 | caplog.set_level(logging.WARNING) 86 | 87 | # Non-TableRenderable models are rendered as JSON 88 | assert t.__rows__() == snapshot( 89 | [ 90 | [ 91 | """\ 92 | { 93 | "foo": "foo", 94 | "bar": 1, 95 | "baz": 1.0, 96 | "qux": [ 97 | "a", 98 | "b" 99 | ] 100 | }\ 101 | """ 102 | ] 103 | ] 104 | ) 105 | 106 | # Check that we logged info on what happened and how we got there 107 | assert caplog.record_tuples == snapshot( 108 | [("zabbix_cli", 30, "Cannot render FooModel as a table.")] 109 | ) 110 | record = caplog.records[0] 111 | assert record.funcName == "__rows__" 112 | assert record.stack_info is not None 113 | assert "test_rows_with_unknown_base_model" in record.stack_info 114 | -------------------------------------------------------------------------------- /tests/test_patches.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import copy 4 | from typing import Any 5 | 6 | import pytest 7 | import typer 8 | import typer.rich_utils 9 | from inline_snapshot import snapshot 10 | from rich.color import Color 11 | from rich.color import ColorType 12 | from rich.console import Console 13 | from rich.segment import Segment 14 | from rich.style import Style 15 | from typer.models import ParameterInfo 16 | from zabbix_cli._patches import typer as typ 17 | from zabbix_cli.exceptions import ZabbixCLIError 18 | from zabbix_cli.pyzabbix.enums import APIStr 19 | from zabbix_cli.pyzabbix.enums import APIStrEnum 20 | 21 | 22 | def test_typer_patches_idempotent() -> None: 23 | # patch() runs all patching functions 24 | typ.patch() 25 | 26 | # Run each patching function again to ensure no errors are raised 27 | # very primitive test, but ensures that we can call it as many times 28 | # as we want without any obvious errors. 29 | typ.patch__get_rich_console() 30 | typ.patch_generate_enum_convertor() 31 | typ.patch_get_click_type() 32 | typ.patch_help_text_spacing() 33 | typ.patch_help_text_style() 34 | 35 | 36 | def test_patch__get_rich_console( 37 | capsys: pytest.CaptureFixture, force_color: Any 38 | ) -> None: 39 | original = copy.deepcopy(typer.rich_utils._get_rich_console) 40 | typ.patch__get_rich_console() 41 | new = typer.rich_utils._get_rich_console 42 | assert original != new 43 | 44 | original_val = original() 45 | new_val = new() 46 | assert original_val != new_val 47 | 48 | assert isinstance(original_val, Console) 49 | assert isinstance(new_val, Console) 50 | 51 | # Test some styles 52 | to_render = "[option]foo[/] [metavar]bar[/]" 53 | rendered = new_val.render(to_render) 54 | assert list(rendered) == snapshot( 55 | [ 56 | Segment( 57 | text="foo", 58 | style=Style( 59 | color=Color("cyan", ColorType.STANDARD, number=6), bold=True 60 | ), 61 | ), 62 | Segment(text=" ", style=Style()), 63 | Segment( 64 | text="bar", 65 | style=Style( 66 | color=Color("yellow", ColorType.STANDARD, number=3), bold=True 67 | ), 68 | ), 69 | Segment(text="\n"), 70 | ] 71 | ) 72 | 73 | # Flush capture buffer 74 | capsys.readouterr() 75 | new_val.print(to_render) 76 | assert capsys.readouterr().out == snapshot( 77 | "\x1b[1;36mfoo\x1b[0m \x1b[1;33mbar\x1b[0m\n" 78 | ) 79 | 80 | 81 | def test_patch_generate_enum_convertor() -> None: 82 | original = copy.deepcopy(typer.main.generate_enum_convertor) 83 | typ.patch_generate_enum_convertor() 84 | new = typer.main.generate_enum_convertor 85 | assert original != new 86 | 87 | class APIEnum(APIStrEnum): 88 | FOO = APIStr("foo", 0) 89 | BAR = APIStr("bar", 1) 90 | 91 | converter = new(APIEnum) 92 | assert converter("foo") == APIEnum.FOO 93 | assert converter("bar") == APIEnum.BAR 94 | assert converter(0) == APIEnum.FOO 95 | assert converter(1) == APIEnum.BAR 96 | assert converter("0") == APIEnum.FOO 97 | assert converter("1") == APIEnum.BAR 98 | 99 | with pytest.raises(ZabbixCLIError): 100 | assert converter("baz") 101 | with pytest.raises(ZabbixCLIError): 102 | assert converter(2) 103 | with pytest.raises(ZabbixCLIError): 104 | assert converter("2") 105 | 106 | 107 | def test_patch_get_click_type() -> None: 108 | original = copy.deepcopy(typer.main.get_click_type) 109 | typ.patch_get_click_type() 110 | new = typer.main.get_click_type 111 | assert original != new 112 | 113 | class APIEnum(APIStrEnum): 114 | FOO = APIStr("foo", 0) 115 | BAR = APIStr("bar", 1) 116 | 117 | assert new( 118 | annotation=APIEnum, parameter_info=ParameterInfo() 119 | ).to_info_dict() == snapshot( 120 | { 121 | "param_type": "Choice", 122 | "name": "choice", 123 | "choices": ["foo", "bar", "0", "1"], 124 | "case_sensitive": True, 125 | } 126 | ) 127 | 128 | assert new( 129 | annotation=APIEnum, parameter_info=ParameterInfo(case_sensitive=False) 130 | ).to_info_dict() == snapshot( 131 | { 132 | "param_type": "Choice", 133 | "name": "choice", 134 | "choices": ["foo", "bar", "0", "1"], 135 | "case_sensitive": False, 136 | } 137 | ) 138 | 139 | 140 | def test_patch_help_text_spacing(ctx: typer.Context) -> None: 141 | original = copy.deepcopy(typer.rich_utils._get_help_text) 142 | typ.patch_help_text_spacing() 143 | new = typer.rich_utils._get_help_text 144 | assert original != new 145 | 146 | ctx.command.help = "This is the first line.\n\nThis is the last line." 147 | help_text = new(obj=ctx.command, markup_mode="rich") 148 | console = typer.rich_utils._get_rich_console() 149 | assert list(console.render(help_text)) == snapshot( 150 | [ 151 | Segment(text="This is the first line."), 152 | Segment(text="\n"), 153 | Segment(text=""), 154 | Segment(text="\n"), 155 | Segment(text="This is the last line."), 156 | Segment(text="\n"), 157 | ] 158 | ) 159 | 160 | 161 | def test_patch_help_text_style() -> None: 162 | # No in-depth testing here - just ensure that the function 163 | # sets the style we expect. 164 | # test_patch_help_text_spacing() tests the actual rendering. 165 | typ.patch_help_text_style() 166 | style = typer.rich_utils.STYLE_HELPTEXT 167 | assert style != "dim" 168 | assert style == snapshot("") 169 | -------------------------------------------------------------------------------- /tests/test_prompts.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | 5 | import pytest 6 | from zabbix_cli.output.prompts import HEADLESS_VARS_SET 7 | from zabbix_cli.output.prompts import TRUE_ARGS 8 | from zabbix_cli.output.prompts import is_headless 9 | 10 | 11 | @pytest.mark.parametrize("envvar", HEADLESS_VARS_SET) 12 | @pytest.mark.parametrize("value", TRUE_ARGS) 13 | def test_is_headless_set_true(envvar: str, value: str): 14 | """Returns True when the envvar is set to a truthy value.""" 15 | _do_test_is_headless(envvar, value, True) 16 | 17 | 18 | @pytest.mark.parametrize("envvar", HEADLESS_VARS_SET) 19 | @pytest.mark.parametrize("value", ["0", "false", "", None]) 20 | def test_is_headless_set_false(envvar: str, value: str): 21 | """Returns False when the envvar is set to a falsey value or unset""" 22 | _do_test_is_headless(envvar, value, False) 23 | 24 | 25 | @pytest.mark.parametrize( 26 | "envvar, value, expected", 27 | [ 28 | ("DEBIAN_FRONTEND", "noninteractive", True), 29 | ("DEBIAN_FRONTEND", "teletype", False), 30 | ("DEBIAN_FRONTEND", "readline", False), 31 | ("DEBIAN_FRONTEND", "dialog", False), 32 | ("DEBIAN_FRONTEND", "gtk", False), 33 | ("DEBIAN_FRONTEND", "text", False), 34 | ("DEBIAN_FRONTEND", "anything", False), 35 | ("DEBIAN_FRONTEND", None, False), 36 | ("DEBIAN_FRONTEND", "", False), 37 | ("DEBIAN_FRONTEND", "0", False), 38 | ("DEBIAN_FRONTEND", "false", False), 39 | ("DEBIAN_FRONTEND", "1", False), 40 | ("DEBIAN_FRONTEND", "true", False), 41 | ], 42 | ) 43 | def test_is_headless_map(envvar: str, value: str, expected: bool) -> None: 44 | """Returns True when the envvar is set to a specific value.""" 45 | _do_test_is_headless(envvar, value, expected) 46 | 47 | 48 | def _do_test_is_headless(envvar: str, value: str | None, expected: bool): 49 | """Helper function for testing is_headless. 50 | 51 | Sets/clears envvar before testing, then clears cache and envvar after test. 52 | """ 53 | _orig_environ = os.environ.copy() 54 | os.environ.clear() 55 | try: 56 | if value is None: 57 | os.environ.pop(envvar, None) 58 | else: 59 | os.environ[envvar] = value 60 | assert is_headless() == expected 61 | finally: 62 | # IMPORTANT: Remove envvar and clear cache after each test 63 | os.environ = _orig_environ # type: ignore # noqa: B003 # I _think_ this is fine? 64 | is_headless.cache_clear() 65 | -------------------------------------------------------------------------------- /tests/test_repl.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from inline_snapshot import snapshot 4 | from zabbix_cli.repl.repl import _help_internal # pyright: ignore[reportPrivateUsage] 5 | 6 | 7 | def test_help_internal() -> None: 8 | assert _help_internal() == snapshot( 9 | """\ 10 | REPL help: 11 | 12 | External Commands: 13 | prefix external commands with "!" 14 | 15 | Internal Commands: 16 | prefix internal commands with ":" 17 | :exit, :q, :quit exits the repl 18 | :?, :h, :help displays general help information 19 | """ 20 | ) 21 | -------------------------------------------------------------------------------- /tests/test_style.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from zabbix_cli.output.style import Color 4 | 5 | 6 | def test_color_call() -> None: 7 | assert Color.INFO("test") == "[default]test[/]" 8 | assert Color.SUCCESS("test") == "[green]test[/]" 9 | assert Color.WARNING("test") == "[yellow]test[/]" 10 | assert Color.ERROR("test") == "[red]test[/]" 11 | 12 | # Ensure it doesnt break normal instantiation with a value 13 | assert Color("default") == Color.INFO 14 | assert Color("yellow") == Color.WARNING 15 | -------------------------------------------------------------------------------- /tests/test_update.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from zabbix_cli.update import PyInstallerUpdater 5 | 6 | 7 | @pytest.mark.parametrize( 8 | "os, arch, version, expect_info", 9 | [ 10 | ("linux", "x86_64", "1.2.3", "1.2.3-linux-x86_64"), 11 | ("linux", "arm64", "1.2.3", "1.2.3-linux-arm64"), 12 | ("linux", "armv7l", "1.2.3", "1.2.3-linux-armv7l"), 13 | ("darwin", "x86_64", "1.2.3", "1.2.3-macos-x86_64"), 14 | ("darwin", "arm64", "1.2.3", "1.2.3-macos-arm64"), 15 | ("win32", "x86_64", "1.2.3", "1.2.3-win-x86_64.exe"), 16 | ], 17 | ) 18 | def test_pyinstaller_updater_get_url( 19 | os: str, arch: str, version: str, expect_info: str 20 | ): 21 | BASE_URL = ( 22 | "https://github.com/unioslo/zabbix-cli/releases/latest/download/zabbix-cli" 23 | ) 24 | expect_url = f"{BASE_URL}-{expect_info}" 25 | 26 | url = PyInstallerUpdater.get_url(os, arch, version) 27 | assert url == expect_url 28 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | from datetime import timedelta 5 | 6 | import pytest 7 | from freezegun import freeze_time 8 | from zabbix_cli.utils import convert_duration 9 | from zabbix_cli.utils.utils import convert_time_to_interval 10 | from zabbix_cli.utils.utils import convert_timestamp 11 | from zabbix_cli.utils.utils import convert_timestamp_interval 12 | 13 | 14 | @pytest.mark.parametrize( 15 | "input,expect", 16 | [ 17 | ( 18 | "1 hour", 19 | timedelta(hours=1), 20 | ), 21 | ( 22 | "1 hour 30 minutes", 23 | timedelta(hours=1, minutes=30), 24 | ), 25 | ( 26 | "1 hour 30 minutes 30 seconds", 27 | timedelta(hours=1, minutes=30, seconds=30), 28 | ), 29 | ( 30 | "1 day 1 hour 30 minutes 30 seconds", 31 | timedelta(days=1, hours=1, minutes=30, seconds=30), 32 | ), 33 | ( 34 | "2 hours 30 seconds", 35 | timedelta(hours=2, seconds=30), 36 | ), 37 | ( 38 | "2h30s", 39 | timedelta(hours=2, seconds=30), 40 | ), 41 | ( 42 | "2 hours 30 minutes 30 seconds", 43 | timedelta(hours=2, minutes=30, seconds=30), 44 | ), 45 | ( 46 | "2 hour 30 minute 30 second", 47 | timedelta(hours=2, minutes=30, seconds=30), 48 | ), 49 | ( 50 | "2h30m30s", 51 | timedelta(hours=2, minutes=30, seconds=30), 52 | ), 53 | ( 54 | "9030s", 55 | timedelta(seconds=9030), 56 | ), 57 | ( 58 | "9030", 59 | timedelta(seconds=9030), 60 | ), 61 | ( 62 | "0", 63 | timedelta(seconds=0), 64 | ), 65 | ( 66 | "0d0h0m0s", 67 | timedelta(days=0, hours=0, minutes=0, seconds=0), 68 | ), 69 | ], 70 | ) 71 | def test_convert_duration(input: str, expect: timedelta) -> None: 72 | assert convert_duration(input) == expect 73 | assert convert_duration(input).total_seconds() == expect.total_seconds() 74 | 75 | 76 | @pytest.mark.parametrize( 77 | "input, expect", 78 | [ 79 | pytest.param( 80 | "2016-11-21T22:00 to 2016-11-21T23:00", 81 | (datetime(2016, 11, 21, 22, 0, 0), datetime(2016, 11, 21, 23, 0, 0)), 82 | id="Legacy format", # (ISO w/o timezone and seconds) 83 | ), 84 | pytest.param( 85 | "2016-11-21T22:00:00 to 2016-11-21T23:00:00", 86 | (datetime(2016, 11, 21, 22, 0, 0), datetime(2016, 11, 21, 23, 0, 0)), 87 | id="ISO w/o timezone", 88 | ), 89 | pytest.param( 90 | "2016-11-21 22:00:00 to 2016-11-21 23:00:00", 91 | (datetime(2016, 11, 21, 22, 0, 0), datetime(2016, 11, 21, 23, 0, 0)), 92 | id="ISO w/o timezone (space-separated)", 93 | ), 94 | ], 95 | ) 96 | def test_convert_timestamp_interval( 97 | input: str, expect: tuple[datetime, datetime] 98 | ) -> None: 99 | assert convert_timestamp_interval(input) == expect 100 | # TODO: test with mix of formats. e.g. "2016-11-21T22:00 to 2016-11-21 23:00:00" 101 | 102 | 103 | @pytest.mark.parametrize( 104 | "input,expect", 105 | [ 106 | pytest.param( 107 | "2016-11-21T22:00", 108 | datetime(2016, 11, 21, 22, 0, 0), 109 | id="Legacy", 110 | ), 111 | pytest.param( 112 | "2016-11-21T22:00:00", 113 | datetime(2016, 11, 21, 22, 0, 0), 114 | id="ISO w/o timezone", 115 | ), 116 | pytest.param( 117 | "2016-11-21 22:00:00", 118 | datetime(2016, 11, 21, 22, 0, 0), 119 | id="ISO w/o timezone (space-separated)", 120 | ), 121 | ], 122 | ) 123 | def test_convert_timestamp(input: str, expect: datetime) -> None: 124 | assert convert_timestamp(input) == expect 125 | 126 | 127 | @pytest.mark.parametrize( 128 | "input,expect_duration", 129 | [ 130 | pytest.param( 131 | "2016-11-21T22:00 to 2016-11-21T23:00", 132 | timedelta(hours=1), 133 | id="Range: Legacy", 134 | ), 135 | pytest.param( 136 | "2016-11-21T22:00:00 to 2016-11-21T23:00:00", 137 | timedelta(hours=1), 138 | id="Range: ISO w/o timezone", 139 | ), 140 | pytest.param( 141 | "2016-11-21 22:00:00 to 2016-11-21 23:00:00", 142 | timedelta(hours=1), 143 | id="Range: ISO w/o timezone (space-separated)", 144 | ), 145 | pytest.param( 146 | "1 hour", 147 | timedelta(hours=1), 148 | id="Duration: 1h (long form)", 149 | ), 150 | pytest.param( 151 | "1 day 1 hour 30 minutes 30 seconds", 152 | timedelta(days=1, hours=1, minutes=30, seconds=30), 153 | id="Duration: 1d1h30m30s (long form)", 154 | ), 155 | pytest.param( 156 | "1h", 157 | timedelta(hours=1), 158 | id="Duration: 1h (short form)", 159 | ), 160 | pytest.param( 161 | "1d1h30m30s", 162 | timedelta(days=1, hours=1, minutes=30, seconds=30), 163 | id="Duration: 1d1h30m30s (short form)", 164 | ), 165 | ], 166 | ) 167 | @freeze_time("2016-11-21 22:00:00") 168 | def test_convert_time_to_interval(input: str, expect_duration: timedelta) -> None: 169 | start, end = convert_time_to_interval(input) 170 | assert start == datetime(2016, 11, 21, 22, 0, 0) 171 | assert end == start + expect_duration 172 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from typing import Any 5 | from typing import Optional 6 | 7 | from pytest_httpserver import HTTPServer 8 | from werkzeug import Request 9 | from werkzeug import Response 10 | from zabbix_cli.pyzabbix.types import Json 11 | 12 | 13 | def add_zabbix_endpoint( 14 | httpserver: HTTPServer, 15 | method: str, # method is zabbix API method, not HTTP method 16 | *, 17 | params: dict[str, Any], 18 | response: Json, 19 | auth: Optional[str] = None, 20 | headers: Optional[dict[str, str]] = None, 21 | id: int = 0, 22 | check_id: bool = False, 23 | ) -> None: 24 | """Add an endpoint mocking a Zabbix API endpoint.""" 25 | 26 | # Use a custom handler to check request contents 27 | def handler(request: Request) -> Response: 28 | # Get the JSON body of the request 29 | # Request has content type 'application/json-rpc' 30 | # so request.json() method does not work 31 | request_json = json.loads(request.data.decode()) 32 | 33 | # Zabbix API method 34 | assert request_json["method"] == method 35 | 36 | # Only check the params we passed are correct 37 | # Missing/extra params are not checked 38 | for k, v in params.items(): 39 | assert k in request_json["params"] 40 | assert request_json["params"][k] == v 41 | 42 | # Test auth token in body (< 6.4.0) 43 | if auth: 44 | assert request_json["auth"] == auth 45 | 46 | # Test headers 47 | if headers: 48 | for k, v in headers.items(): 49 | assert request.headers[k] == v 50 | 51 | # Only check ID if we are told to 52 | # In parametrized tests, it can be difficult to determine the ID 53 | # depending on auth method, server version, etc. 54 | if check_id: 55 | assert request_json["id"] == id 56 | 57 | resp_json = json.dumps({"jsonrpc": "2.0", "result": response, "id": id}) 58 | return Response(resp_json, status=200, content_type="application/json") 59 | 60 | httpserver.expect_oneshot_request( 61 | "/api_jsonrpc.php", 62 | method="POST", 63 | ).respond_with_handler(handler) 64 | 65 | 66 | def add_zabbix_version_endpoint( 67 | httpserver: HTTPServer, version: str, id: int = 0 68 | ) -> None: 69 | """Add an endpoint emulating the Zabbix apiiinfo.version method.""" 70 | add_zabbix_endpoint( 71 | httpserver, 72 | method="apiinfo.version", 73 | params={}, 74 | response=version, 75 | id=id, 76 | ) 77 | -------------------------------------------------------------------------------- /zabbix_cli/__about__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | __version__ = "3.5.2" 4 | APP_NAME = "zabbix-cli" 5 | AUTHOR = "unioslo" 6 | -------------------------------------------------------------------------------- /zabbix_cli/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from zabbix_cli._patches import patch_all 4 | 5 | patch_all() 6 | -------------------------------------------------------------------------------- /zabbix_cli/__main__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from zabbix_cli.main import main 4 | 5 | if __name__ == "__main__": 6 | main() 7 | -------------------------------------------------------------------------------- /zabbix_cli/_patches/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from zabbix_cli._patches import typer as typ 4 | 5 | 6 | def patch_all() -> None: 7 | """Apply all patches to all modules.""" 8 | typ.patch() 9 | -------------------------------------------------------------------------------- /zabbix_cli/_patches/common.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC 4 | from abc import abstractmethod 5 | from typing import TYPE_CHECKING 6 | 7 | if TYPE_CHECKING: 8 | from types import TracebackType 9 | from typing import Optional 10 | 11 | 12 | class BasePatcher(ABC): 13 | """Context manager that logs and prints diagnostic info if an exception 14 | occurs. 15 | """ 16 | 17 | def __init__(self, description: str) -> None: 18 | self.description = description 19 | 20 | @abstractmethod 21 | def __package_info__(self) -> str: 22 | raise NotImplementedError 23 | 24 | def __enter__(self) -> BasePatcher: 25 | return self 26 | 27 | def __exit__( 28 | self, 29 | exc_type: Optional[type[BaseException]], 30 | exc_val: Optional[BaseException], 31 | exc_tb: Optional[TracebackType], 32 | ) -> bool: 33 | if not exc_type: 34 | return True 35 | import sys 36 | 37 | import rich 38 | from rich.table import Table 39 | 40 | from zabbix_cli.__about__ import __version__ 41 | 42 | # Rudimentary, but provides enough info to debug and fix the issue 43 | console = rich.console.Console(stderr=True) 44 | console.print_exception() 45 | console.print() 46 | table = Table( 47 | title="Diagnostics", 48 | show_header=False, 49 | show_lines=False, 50 | ) 51 | table.add_row( 52 | "[b]Package [/]", 53 | self.__package_info__(), 54 | ) 55 | table.add_row( 56 | "[b]zabbix-cli [/]", 57 | __version__, 58 | ) 59 | table.add_row( 60 | "[b]Python [/]", 61 | sys.version, 62 | ) 63 | table.add_row( 64 | "[b]Platform [/]", 65 | sys.platform, 66 | ) 67 | console.print(table) 68 | console.print(f"[bold red]ERROR: Failed to patch {self.description}[/]") 69 | raise SystemExit(1) 70 | 71 | 72 | def get_patcher(info: str) -> type[BasePatcher]: 73 | """Returns a patcher for a given package.""" 74 | 75 | class Patcher(BasePatcher): 76 | def __package_info__(self) -> str: 77 | return info 78 | 79 | return Patcher 80 | -------------------------------------------------------------------------------- /zabbix_cli/_types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | 5 | if sys.version_info >= (3, 10): 6 | from types import EllipsisType 7 | 8 | EllipsisType = EllipsisType 9 | else: 10 | from typing import Any 11 | 12 | EllipsisType = Any 13 | -------------------------------------------------------------------------------- /zabbix_cli/_v2_compat.py: -------------------------------------------------------------------------------- 1 | """Compatibility functions going from Zabbix-CLI v2 to v3. 2 | 3 | The functions in this module are intended to ease the transition by 4 | providing fallbacks to deprecated functionality in Zabbix-CLI v2. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import os 10 | import shlex 11 | from pathlib import Path 12 | from typing import Optional 13 | 14 | import typer 15 | from click.core import CommandCollection 16 | from click.core import Group 17 | 18 | CONFIG_FILENAME = "zabbix-cli.conf" 19 | CONFIG_FIXED_NAME = "zabbix-cli.fixed.conf" 20 | 21 | # Config file locations 22 | CONFIG_DEFAULT_DIR = "/usr/share/zabbix-cli" 23 | CONFIG_SYSTEM_DIR = "/etc/zabbix-cli" 24 | CONFIG_USER_DIR = os.path.expanduser("~/.zabbix-cli") 25 | 26 | # Any item will overwrite values from the previous (NYI) 27 | CONFIG_PRIORITY = tuple( 28 | Path(os.path.join(d, f)) 29 | for d, f in ( 30 | (CONFIG_DEFAULT_DIR, CONFIG_FIXED_NAME), 31 | (CONFIG_SYSTEM_DIR, CONFIG_FIXED_NAME), 32 | (CONFIG_USER_DIR, CONFIG_FILENAME), 33 | (CONFIG_SYSTEM_DIR, CONFIG_FILENAME), 34 | (CONFIG_DEFAULT_DIR, CONFIG_FILENAME), 35 | ) 36 | ) 37 | 38 | 39 | AUTH_FILE = Path.home() / ".zabbix-cli_auth" 40 | AUTH_TOKEN_FILE = Path.home() / ".zabbix-cli_auth_token" 41 | 42 | 43 | def run_command_from_option(ctx: typer.Context, command: str) -> None: 44 | """Runs a command via old-style --command/-C option.""" 45 | from zabbix_cli.output.console import error 46 | from zabbix_cli.output.console import exit_err 47 | from zabbix_cli.output.console import warning 48 | 49 | warning( 50 | "The [i]--command/-C[/] option is deprecated and will be removed in a future release. " 51 | "Invoke command directly instead." 52 | ) 53 | if not isinstance(ctx.command, (CommandCollection, Group)): 54 | exit_err( # TODO: find out if this could ever happen? 55 | f"Cannot run command {command!r}. Ensure it is a valid command and try again." 56 | ) 57 | 58 | parts = shlex.split(command, comments=True) 59 | if not parts: 60 | exit_err( 61 | f"Command {command!r} is empty. Ensure it is a valid command and try again." 62 | ) 63 | 64 | try: 65 | with ctx.command.make_context(None, parts, parent=ctx) as new_ctx: 66 | ctx.command.invoke(new_ctx) 67 | except typer.Exit: 68 | pass 69 | except Exception as e: 70 | error( 71 | f"Command {command!r} failed with error: {e}. Try re-running without --command." 72 | ) 73 | raise 74 | 75 | 76 | def args_callback( 77 | ctx: typer.Context, value: Optional[list[str]] 78 | ) -> Optional[list[str]]: 79 | if ctx.resilient_parsing: 80 | return # for auto-completion 81 | if value: 82 | from zabbix_cli.output.console import warning 83 | 84 | warning( 85 | f"Detected deprecated positional arguments {value}. Use options instead." 86 | ) 87 | # NOTE: Must NEVER return None. The "fix" in Typer 0.10.0 for None defaults 88 | # somehow broke the parsing of callback values by causing values returned by 89 | # callbacks to be passed to the internal converter, which then fails 90 | # because it expects a list but gets None. 91 | # https://github.com/tiangolo/typer/pull/664 92 | # https://github.com/tiangolo/typer/blob/142422a14ca4c6a8ad579e9bd0fd0728364d86e3/typer/main.py#L639 93 | return value or [] 94 | 95 | 96 | ARGS_POSITIONAL = typer.Argument( 97 | None, 98 | help="DEPRECATED: V2-style positional arguments.", 99 | show_default=False, 100 | hidden=True, 101 | callback=args_callback, 102 | ) 103 | -------------------------------------------------------------------------------- /zabbix_cli/app/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from zabbix_cli.commands import bootstrap_commands # type: ignore # noqa: E402, F401 4 | 5 | from .app import * # noqa: F403 # wildcard import to avoid circular import (why?) 6 | from .app import StatefulApp # explicit import for type checker 7 | 8 | app = StatefulApp( 9 | name="zabbix-cli", 10 | help="Zabbix-CLI is a command line interface for Zabbix.", 11 | add_completion=True, 12 | rich_markup_mode="rich", 13 | ) 14 | 15 | # Import commands to register them with the app 16 | from zabbix_cli.commands import cli # type: ignore # noqa: E402, F401, I001 17 | from zabbix_cli.commands import export # type: ignore # noqa: E402, F401 18 | from zabbix_cli.commands import host # type: ignore # noqa: E402, F401 19 | from zabbix_cli.commands import host_interface # type: ignore # noqa: E402, F401 20 | from zabbix_cli.commands import hostgroup # type: ignore # noqa: E402, F401 21 | from zabbix_cli.commands import host_monitoring # type: ignore # noqa: E402, F401 22 | from zabbix_cli.commands import item # type: ignore # noqa: E402, F401 23 | from zabbix_cli.commands import macro # type: ignore # noqa: E402, F401 24 | from zabbix_cli.commands import maintenance # type: ignore # noqa: E402, F401 25 | from zabbix_cli.commands import media # type: ignore # noqa: E402, F401 26 | from zabbix_cli.commands import problem # type: ignore # noqa: E402, F401 27 | from zabbix_cli.commands import proxy # type: ignore # noqa: E402, F401 28 | from zabbix_cli.commands import template # type: ignore # noqa: E402, F401 29 | from zabbix_cli.commands import templategroup # type: ignore # noqa: E402, F401 30 | from zabbix_cli.commands import user # type: ignore # noqa: E402, F401 31 | from zabbix_cli.commands import usergroup # type: ignore # noqa: E402, F401 32 | 33 | 34 | # Import dev commands 35 | # TODO: Disable by default, enable with a flag. 36 | bootstrap_commands() 37 | -------------------------------------------------------------------------------- /zabbix_cli/cache.py: -------------------------------------------------------------------------------- 1 | """Simple in-memory caching of frequently used Zabbix objects.""" 2 | 3 | # TODO: add on/off toggle for caching 4 | from __future__ import annotations 5 | 6 | import logging 7 | from typing import TYPE_CHECKING 8 | from typing import Optional 9 | 10 | from zabbix_cli.exceptions import ZabbixCLIError 11 | 12 | if TYPE_CHECKING: 13 | from zabbix_cli.pyzabbix.client import ZabbixAPI 14 | 15 | 16 | class ZabbixCache: 17 | """In-memory cache of frequently used Zabbix objects.""" 18 | 19 | def __init__(self, client: ZabbixAPI) -> None: 20 | self.client = client 21 | self._hostgroup_name_cache: dict[str, str] = {} 22 | """Mapping of hostgroup names to hostgroup IDs""" 23 | 24 | self._hostgroup_id_cache: dict[str, str] = {} 25 | """Mapping of hostgroup IDs to hostgroup names""" 26 | 27 | self._templategroup_name_cache: dict[str, str] = {} 28 | """Mapping of templategroup names to templategroup IDs""" 29 | 30 | self._templategroup_id_cache: dict[str, str] = {} # NOTE: unused 31 | """Mapping of templategroup IDs to templategroup names""" 32 | 33 | def populate(self) -> None: 34 | try: 35 | self._populate_hostgroup_cache() 36 | self._populate_templategroup_cache() 37 | except Exception as e: 38 | raise ZabbixCLIError(f"Failed to populate Zabbix cache: {e}") from e 39 | 40 | def _populate_hostgroup_cache(self) -> None: 41 | """Populates the hostgroup caches with data from the Zabbix API.""" 42 | hostgroups = self.client.hostgroup.get(output=["name", "groupid"]) 43 | self._hostgroup_name_cache = { 44 | hostgroup["name"]: hostgroup["groupid"] for hostgroup in hostgroups 45 | } 46 | self._hostgroup_id_cache = { 47 | hostgroup["groupid"]: hostgroup["name"] for hostgroup in hostgroups 48 | } 49 | 50 | def _populate_templategroup_cache(self) -> None: 51 | """Populates the templategroup caches with data from the Zabbix API 52 | on Zabbix >= 6.2.0. 53 | """ 54 | if self.client.version.release < (6, 2, 0): 55 | logging.debug( 56 | "Skipping template group caching. API version is %s", 57 | self.client.version, 58 | ) 59 | return 60 | 61 | templategroups = self.client.templategroup.get(output=["name", "groupid"]) 62 | self._templategroup_name_cache = { 63 | templategroup["name"]: templategroup["groupid"] 64 | for templategroup in templategroups 65 | } 66 | self._templategroup_id_cache = { 67 | templategroup["groupid"]: templategroup["name"] 68 | for templategroup in templategroups 69 | } 70 | 71 | def get_hostgroup_name(self, hostgroup_id: str) -> Optional[str]: 72 | """Returns the name of a host group given its ID.""" 73 | return self._hostgroup_id_cache.get(hostgroup_id) 74 | 75 | def get_hostgroup_id(self, hostgroup_name: str) -> Optional[str]: 76 | """Returns the ID of a host group given its name.""" 77 | return self._hostgroup_name_cache.get(hostgroup_name) 78 | 79 | def get_templategroup_name(self, templategroup_id: str) -> Optional[str]: 80 | """Returns the name of a template group given its ID.""" 81 | return self._templategroup_id_cache.get(templategroup_id) 82 | 83 | def get_templategroup_id(self, templategroup_name: str) -> Optional[str]: 84 | """Returns the ID of a template group given its name.""" 85 | return self._templategroup_name_cache.get(templategroup_name) 86 | -------------------------------------------------------------------------------- /zabbix_cli/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import importlib 4 | from pathlib import Path 5 | 6 | 7 | def bootstrap_commands() -> None: 8 | """Bootstrap all command defined in the command modules.""" 9 | module_dir = Path(__file__).parent 10 | for module in module_dir.glob("*.py"): 11 | if module.stem == "__init__": 12 | continue 13 | importlib.import_module(f".{module.stem}", package=__package__) 14 | -------------------------------------------------------------------------------- /zabbix_cli/commands/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unioslo/zabbix-cli/89a4eac093114b8d39cdc32bd630d40fabe9affc/zabbix_cli/commands/common/__init__.py -------------------------------------------------------------------------------- /zabbix_cli/commands/common/args.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | from typing import Optional 5 | 6 | import click 7 | import typer 8 | from click.types import ParamType 9 | from typer.core import TyperGroup 10 | 11 | from zabbix_cli.logs import logger 12 | 13 | 14 | def get_limit_option( 15 | limit: Optional[int] = 0, 16 | resource: str = "results", 17 | long_option: str = "--limit", 18 | short_option: str = "-n", 19 | ) -> Any: # TODO: Can we type this better? 20 | """Limit option factory.""" 21 | return typer.Option( 22 | limit, 23 | long_option, 24 | short_option, 25 | help=f"Limit the number of {resource}. 0 to show all.", 26 | ) 27 | 28 | 29 | OPTION_LIMIT = get_limit_option(0) 30 | 31 | ARG_TEMPLATE_NAMES_OR_IDS = typer.Argument( 32 | help="Template names or IDs. Comma-separated. Supports wildcards.", 33 | show_default=False, 34 | ) 35 | ARG_HOSTNAMES_OR_IDS = typer.Argument( 36 | help="Hostnames or IDs. Comma-separated. Supports wildcards.", 37 | show_default=False, 38 | ) 39 | ARG_GROUP_NAMES_OR_IDS = typer.Argument( 40 | help="Host/template group names or IDs. Comma-separated. Supports wildcards.", 41 | show_default=False, 42 | ) 43 | 44 | 45 | class CommandParam(ParamType): 46 | """Command param type that resolves into a click Command.""" 47 | 48 | name = "command" 49 | 50 | def convert( 51 | self, value: str, param: Optional[click.Parameter], ctx: Optional[click.Context] 52 | ) -> click.Command: 53 | if not value: 54 | self.fail("Missing command.", param, ctx) 55 | if not ctx: 56 | self.fail("No context.", param, ctx) 57 | root_ctx = ctx.find_root() 58 | root_command = root_ctx.command 59 | 60 | if not isinstance(root_command, TyperGroup): 61 | logger.error( 62 | "Root context of %s is not a TyperGroup, unable to show help", 63 | root_command, 64 | ) 65 | self.fail(f"Unable to show help for '{value}'") 66 | 67 | cmd = root_command.get_command(root_ctx, value) 68 | if not cmd: 69 | self.fail(f"Command '{value}' not found.") 70 | return cmd 71 | -------------------------------------------------------------------------------- /zabbix_cli/commands/host_monitoring.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typer 4 | 5 | from zabbix_cli.app import app 6 | from zabbix_cli.output.render import render_result 7 | from zabbix_cli.pyzabbix.enums import MonitoringStatus 8 | 9 | HELP_PANEL = "Host Monitoring" 10 | 11 | 12 | @app.command( 13 | name="define_host_monitoring_status", 14 | rich_help_panel=HELP_PANEL, 15 | hidden=True, 16 | deprecated=True, 17 | ) 18 | @app.command(name="monitor_host", rich_help_panel=HELP_PANEL) 19 | def monitor_host( 20 | hostname: str = typer.Argument( 21 | help="Name of host", 22 | show_default=False, 23 | ), 24 | new_status: MonitoringStatus = typer.Argument( 25 | help="Monitoring status", 26 | case_sensitive=False, 27 | show_default=False, 28 | ), 29 | ) -> None: 30 | """Monitor or unmonitor a host.""" 31 | from zabbix_cli.models import Result 32 | 33 | host = app.state.client.get_host(hostname) 34 | app.state.client.update_host_status(host, new_status) 35 | render_result( 36 | Result( 37 | message=f"Updated host {hostname!r}. New monitoring status: {new_status}" 38 | ) 39 | ) 40 | 41 | 42 | @app.command(name="show_host_inventory", rich_help_panel=HELP_PANEL) 43 | def show_host_inventory( 44 | hostname_or_id: str = typer.Argument( 45 | help="Hostname or ID", 46 | show_default=False, 47 | ), 48 | ) -> None: 49 | """Show host inventory details for a specific host.""" 50 | # TODO: support undocumented filter argument from V2 51 | # TODO: Add mapping of inventory keys to human readable names (Web GUI names) 52 | host = app.state.client.get_host(hostname_or_id, select_inventory=True) 53 | render_result(host.inventory) 54 | 55 | 56 | @app.command(name="update_host_inventory", rich_help_panel=HELP_PANEL) 57 | def update_host_inventory( 58 | ctx: typer.Context, 59 | hostname_or_id: str = typer.Argument( 60 | help="Hostname or ID of host.", 61 | show_default=False, 62 | ), 63 | key: str = typer.Argument( 64 | help="Inventory key", 65 | show_default=False, 66 | ), 67 | value: str = typer.Argument( 68 | help="Inventory value", 69 | show_default=False, 70 | ), 71 | ) -> None: 72 | """Update a host inventory field. 73 | 74 | Inventory fields in the API do not always match Web GUI field names. 75 | Use `zabbix-cli -o json show_host_inventory ` to see the available fields. 76 | """ 77 | from zabbix_cli.models import Result 78 | 79 | host = app.state.client.get_host(hostname_or_id) 80 | to_update = {key: value} 81 | app.state.client.update_host_inventory(host, to_update) 82 | render_result(Result(message=f"Updated inventory field {key!r} for host {host}.")) 83 | -------------------------------------------------------------------------------- /zabbix_cli/commands/item.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Optional 4 | 5 | import typer 6 | 7 | from zabbix_cli._v2_compat import ARGS_POSITIONAL 8 | from zabbix_cli.app import Example 9 | from zabbix_cli.app import app 10 | from zabbix_cli.commands.common.args import OPTION_LIMIT 11 | from zabbix_cli.output.console import exit_err 12 | from zabbix_cli.output.render import render_result 13 | from zabbix_cli.utils.args import parse_list_arg 14 | 15 | 16 | @app.command( 17 | name="show_last_values", 18 | rich_help_panel="Host Monitoring", # Moved to host monitoring for now 19 | examples=[ 20 | Example( 21 | "Get items starting with 'MongoDB'", 22 | "show_last_values 'MongoDB*'", 23 | ), 24 | Example( 25 | "Get items containing 'memory'", 26 | "show_last_values '*memory*'", 27 | ), 28 | Example( 29 | "Get all items (WARNING: slow!)", 30 | "show_last_values '*'", 31 | ), 32 | ], 33 | ) 34 | def show_last_values( 35 | ctx: typer.Context, 36 | item: str = typer.Argument( 37 | help="Item names or IDs. Comma-separated. Supports wildcards.", 38 | show_default=False, 39 | ), 40 | group: bool = typer.Option( 41 | False, "--group", help="Group items with the same value." 42 | ), 43 | limit: Optional[int] = OPTION_LIMIT, 44 | args: Optional[list[str]] = ARGS_POSITIONAL, 45 | ) -> None: 46 | """Show the last values of given items of monitored hosts.""" 47 | from zabbix_cli.commands.results.item import ItemResult 48 | from zabbix_cli.commands.results.item import group_items 49 | from zabbix_cli.models import AggregateResult 50 | 51 | if args: 52 | if not len(args) == 1: 53 | exit_err("Invalid number of positional arguments. Use options instead.") 54 | group = args[0] == "1" 55 | # No format arg in V2... 56 | 57 | names_or_ids = parse_list_arg(item) 58 | with app.status("Fetching items..."): 59 | items = app.state.client.get_items( 60 | *names_or_ids, select_hosts=True, monitored=True, limit=limit 61 | ) 62 | 63 | # HACK: not super elegant, but this allows us to match V2 output while 64 | # with and without the --group flag, as well as ALSO rendering the entire 65 | # Item object instead of just a subset of fields. 66 | # Ideally, it would be nice to not have to re-validate when not grouping 67 | # but I'm not sure how to do that in Pydantic V2? 68 | if group: 69 | res = group_items(items) 70 | render_result(AggregateResult(result=res)) 71 | else: 72 | res = [ItemResult.from_item(item) for item in items] 73 | render_result(AggregateResult(result=res)) 74 | -------------------------------------------------------------------------------- /zabbix_cli/commands/media.py: -------------------------------------------------------------------------------- 1 | """Commands for managing media.""" 2 | 3 | from __future__ import annotations 4 | 5 | import typer 6 | 7 | from zabbix_cli.app import app 8 | from zabbix_cli.output.render import render_result 9 | 10 | HELP_PANEL = "Media" 11 | 12 | 13 | @app.command("show_media_types", rich_help_panel=HELP_PANEL) 14 | def show_media_types(ctx: typer.Context) -> None: 15 | """Show all available media types.""" 16 | from zabbix_cli.models import AggregateResult 17 | 18 | media_types = app.state.client.get_mediatypes() 19 | 20 | render_result(AggregateResult(result=media_types)) 21 | -------------------------------------------------------------------------------- /zabbix_cli/commands/results/__init__.py: -------------------------------------------------------------------------------- 1 | """Models for rendering results of commands. 2 | 3 | Should not be imported on startup, as we don't want to build Pydantic models 4 | until we actually need them - this has a massive startup time impact. 5 | 6 | Each command module should have a corresponding module in this package that 7 | defines the models for its results. i.e. `zabbix_cli.commands.host` should 8 | define its result models in `zabbix_cli.commands.results.host`. 9 | """ 10 | 11 | from __future__ import annotations 12 | -------------------------------------------------------------------------------- /zabbix_cli/commands/results/cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from pathlib import Path 5 | from typing import TYPE_CHECKING 6 | from typing import Any 7 | from typing import Optional 8 | 9 | from packaging.version import Version 10 | from pydantic import ConfigDict 11 | from pydantic import FieldSerializationInfo 12 | from pydantic import field_serializer 13 | from rich.box import SIMPLE_HEAD 14 | from rich.table import Table 15 | from typing_extensions import Self 16 | from typing_extensions import TypedDict 17 | 18 | from zabbix_cli.models import TableRenderable 19 | from zabbix_cli.output.console import error 20 | from zabbix_cli.output.formatting.path import path_link 21 | 22 | if TYPE_CHECKING: 23 | from zabbix_cli.commands.cli import DirectoryType 24 | from zabbix_cli.models import ColsRowsType 25 | from zabbix_cli.models import RowsType 26 | from zabbix_cli.state import State 27 | 28 | 29 | class ImplementationInfo(TypedDict): 30 | name: str 31 | version: tuple[Any, ...] 32 | hexversion: int 33 | cache_tag: str 34 | 35 | 36 | class PythonInfo(TypedDict): 37 | version: str 38 | implementation: ImplementationInfo 39 | platform: str 40 | 41 | 42 | class DebugInfo(TableRenderable): 43 | config_path: Optional[Path] = None 44 | api_version: Optional[Version] = None 45 | url: Optional[str] = None 46 | user: Optional[str] = None 47 | auth_token: Optional[str] = None 48 | connected_to_zabbix: bool = False 49 | python: PythonInfo 50 | 51 | model_config = ConfigDict(arbitrary_types_allowed=True) 52 | 53 | @field_serializer("api_version") 54 | def ser_api_version(self, _info: FieldSerializationInfo) -> str: 55 | return str(self.api_version) 56 | 57 | @property 58 | def config_path_str(self) -> str: 59 | return ( 60 | path_link(self.config_path) if self.config_path else str(self.config_path) 61 | ) 62 | 63 | @classmethod 64 | def from_debug_data(cls, state: State, *, with_auth: bool = False) -> DebugInfo: 65 | # So far we only use state, but we can expand this in the future 66 | from zabbix_cli.exceptions import ZabbixCLIError 67 | 68 | obj = cls( 69 | python={ 70 | "version": sys.version, 71 | "implementation": { 72 | "name": sys.implementation.name, 73 | "version": sys.implementation.version, 74 | "hexversion": sys.implementation.hexversion, 75 | "cache_tag": sys.implementation.cache_tag, 76 | }, 77 | "platform": sys.platform, 78 | } 79 | ) 80 | 81 | # Config might not be configured 82 | try: 83 | obj.config_path = state.config.config_path 84 | except ZabbixCLIError: 85 | pass 86 | 87 | # We might not be connected to the API 88 | try: 89 | obj.api_version = state.client.version 90 | obj.url = state.client.url 91 | obj.user = state.config.api.username 92 | if with_auth: 93 | obj.auth_token = state.client.auth 94 | obj.connected_to_zabbix = True 95 | except ZabbixCLIError: 96 | error("Unable to retrieve API info: Not connected to Zabbix API. ") 97 | except Exception as e: 98 | error(f"Unable to retrieve API info: {e}") 99 | return obj 100 | 101 | def as_table(self) -> Table: 102 | table = Table(title="Debug Info") 103 | table.add_column("Key", justify="right", style="cyan") 104 | table.add_column("Value", justify="left", style="magenta") 105 | 106 | table.add_row("Config File", self.config_path_str) 107 | table.add_row("API URL", str(self.url)) 108 | table.add_row("API Version", str(self.api_version)) 109 | table.add_row("User", str(self.user)) 110 | table.add_row("Auth Token", str(self.auth_token)) 111 | table.add_row("Connected to Zabbix", str(self.connected_to_zabbix)) 112 | table.add_row("Python Version", str(self.python["version"])) 113 | table.add_row("Platform", str(self.python["platform"])) 114 | 115 | return table 116 | 117 | 118 | class HistoryResult(TableRenderable): 119 | """Result type for `show_history` command.""" 120 | 121 | __show_lines__ = False 122 | __box__ = SIMPLE_HEAD 123 | 124 | commands: list[str] = [] 125 | 126 | 127 | class DirectoriesResult(TableRenderable): 128 | """Result type for `show_dirs` command.""" 129 | 130 | directories: list[dict[str, Path]] = [] 131 | 132 | @classmethod 133 | def from_directory_types(cls, dirs: list[DirectoryType]) -> Self: 134 | return cls(directories=[{str(d.value): d.as_path()} for d in dirs]) 135 | 136 | def __cols_rows__(self) -> ColsRowsType: 137 | from zabbix_cli.output.style import Emoji 138 | 139 | cols = ["Type", "Path", "Exists"] 140 | rows: RowsType = [] 141 | for d in self.directories: 142 | for key in d: 143 | p = d[key] 144 | exists = Emoji.fmt_bool(p.exists()) 145 | rows.append([key, str(d[key]), exists]) 146 | return cols, rows 147 | -------------------------------------------------------------------------------- /zabbix_cli/commands/results/export.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | from typing import TYPE_CHECKING 5 | from typing import Optional 6 | 7 | from pydantic import field_serializer 8 | 9 | from zabbix_cli.commands.export import ExportType 10 | from zabbix_cli.models import TableRenderable 11 | from zabbix_cli.output.formatting.path import path_link 12 | from zabbix_cli.pyzabbix.enums import ExportFormat 13 | 14 | if TYPE_CHECKING: 15 | from zabbix_cli.models import ColsRowsType 16 | from zabbix_cli.models import RowsType 17 | 18 | 19 | class ExportResult(TableRenderable): 20 | """Result type for `export_configuration` command.""" 21 | 22 | exported: list[Path] = [] 23 | """List of paths to exported files.""" 24 | types: list[ExportType] = [] 25 | names: list[str] = [] 26 | format: ExportFormat 27 | 28 | 29 | class ImportResult(TableRenderable): 30 | """Result type for `import_configuration` command.""" 31 | 32 | success: bool = True 33 | dryrun: bool = False 34 | imported: list[Path] = [] 35 | failed: list[Path] = [] 36 | duration: Optional[float] = None 37 | """Duration it took to import files in seconds. Is None if import failed.""" 38 | 39 | @field_serializer("imported", "failed", when_used="json") 40 | def _serialize_files(self, files: list[Path]) -> list[str]: 41 | """Serializes files as list of normalized, absolute paths with symlinks resolved.""" 42 | return [str(f.resolve()) for f in files] 43 | 44 | def __cols_rows__(self) -> ColsRowsType: 45 | cols: list[str] = ["Imported", "Failed"] 46 | rows: RowsType = [ 47 | [ 48 | "\n".join(path_link(f) for f in self.imported), 49 | "\n".join(path_link(f) for f in self.failed), 50 | ] 51 | ] 52 | return cols, rows 53 | -------------------------------------------------------------------------------- /zabbix_cli/commands/results/host.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Optional 4 | 5 | from pydantic import BaseModel 6 | from pydantic import ConfigDict 7 | 8 | from zabbix_cli.exceptions import ZabbixCLIError 9 | from zabbix_cli.pyzabbix.enums import ActiveInterface 10 | from zabbix_cli.pyzabbix.enums import MaintenanceStatus 11 | from zabbix_cli.pyzabbix.enums import MonitoringStatus 12 | 13 | 14 | # TODO: don't use BaseModel for this 15 | # Use a normal class with __init__ instead 16 | class HostFilterArgs(BaseModel): 17 | """Unified processing of old filter string and new filter options.""" 18 | 19 | active: Optional[ActiveInterface] = None 20 | maintenance_status: Optional[MaintenanceStatus] = None 21 | status: Optional[MonitoringStatus] = None 22 | 23 | model_config = ConfigDict(validate_assignment=True) 24 | 25 | @classmethod 26 | def from_command_args( 27 | cls, 28 | filter_legacy: Optional[str], 29 | active: Optional[ActiveInterface], 30 | maintenance: Optional[bool], 31 | monitored: Optional[bool], 32 | ) -> HostFilterArgs: 33 | args = cls() 34 | if filter_legacy: 35 | items = filter_legacy.split(",") 36 | for item in items: 37 | try: 38 | key, value = (s.strip("'\"") for s in item.split(":")) 39 | except ValueError as e: 40 | raise ZabbixCLIError( 41 | f"Failed to parse filter argument at: {item!r}" 42 | ) from e 43 | if key == "available": 44 | args.active = ActiveInterface(value) 45 | elif key == "maintenance": 46 | args.maintenance_status = MaintenanceStatus(value) 47 | elif key == "status": 48 | args.status = MonitoringStatus(value) 49 | else: 50 | if active is not None: 51 | args.active = active 52 | if monitored is not None: 53 | # Inverted API values (0 = ON, 1 = OFF) - use enums directly 54 | args.status = MonitoringStatus.ON if monitored else MonitoringStatus.OFF 55 | if maintenance is not None: 56 | args.maintenance_status = ( 57 | MaintenanceStatus.ON if maintenance else MaintenanceStatus.OFF 58 | ) 59 | return args 60 | -------------------------------------------------------------------------------- /zabbix_cli/commands/results/hostgroup.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | from typing import Optional 5 | 6 | from pydantic import computed_field 7 | from typing_extensions import TypedDict 8 | 9 | from zabbix_cli.models import TableRenderable 10 | from zabbix_cli.pyzabbix.enums import HostgroupFlag 11 | from zabbix_cli.pyzabbix.enums import HostgroupType 12 | from zabbix_cli.pyzabbix.types import Host 13 | from zabbix_cli.pyzabbix.types import HostGroup 14 | from zabbix_cli.pyzabbix.types import HostList 15 | 16 | if TYPE_CHECKING: 17 | from zabbix_cli.models import ColsRowsType 18 | from zabbix_cli.models import RowsType 19 | 20 | 21 | class AddHostsToHostGroup(TableRenderable): 22 | """Result type for `add_host_to_hostgroup` and `remove_host_from_hostgroup` commands.""" 23 | 24 | hostgroup: str 25 | hosts: list[str] 26 | 27 | @classmethod 28 | def from_result( 29 | cls, 30 | hosts: list[Host], 31 | hostgroup: HostGroup, 32 | ) -> AddHostsToHostGroup: 33 | to_add: set[str] = set() # names of templates to link 34 | for host in hosts: 35 | for hg_host in hostgroup.hosts: 36 | if host.host == hg_host.host: 37 | break 38 | else: 39 | to_add.add(host.host) 40 | return cls( 41 | hostgroup=hostgroup.name, 42 | hosts=sorted(to_add), 43 | ) 44 | 45 | 46 | class RemoveHostsFromHostGroup(TableRenderable): 47 | """Result type for `remove_host_from_hostgroup`.""" 48 | 49 | hostgroup: str 50 | hosts: list[str] 51 | 52 | @classmethod 53 | def from_result( 54 | cls, 55 | hosts: list[Host], 56 | hostgroup: HostGroup, 57 | ) -> RemoveHostsFromHostGroup: 58 | to_remove: set[str] = set() # names of templates to link 59 | for host in hosts: 60 | for hg_host in hostgroup.hosts: 61 | if host.host == hg_host.host: 62 | to_remove.add(host.host) 63 | break 64 | return cls( 65 | hostgroup=hostgroup.name, 66 | hosts=sorted(to_remove), 67 | ) 68 | 69 | 70 | class ExtendHostgroupResult(TableRenderable): 71 | """Result type for `extend_hostgroup` command.""" 72 | 73 | source: str 74 | destination: list[str] 75 | hosts: list[str] 76 | 77 | @classmethod 78 | def from_result( 79 | cls, source: HostGroup, destination: list[HostGroup] 80 | ) -> ExtendHostgroupResult: 81 | return cls( 82 | source=source.name, 83 | destination=[dst.name for dst in destination], 84 | hosts=[host.host for host in source.hosts], 85 | ) 86 | 87 | 88 | class MoveHostsResult(TableRenderable): 89 | """Result type for `move_hosts` command.""" 90 | 91 | source: str 92 | destination: str 93 | hosts: list[str] 94 | 95 | @classmethod 96 | def from_result(cls, source: HostGroup, destination: HostGroup) -> MoveHostsResult: 97 | return cls( 98 | source=source.name, 99 | destination=destination.name, 100 | hosts=[host.host for host in source.hosts], 101 | ) 102 | 103 | 104 | class HostGroupDeleteResult(TableRenderable): 105 | groups: list[str] 106 | 107 | 108 | class HostGroupHost(TypedDict): 109 | hostid: str 110 | host: str 111 | 112 | 113 | class HostGroupResult(TableRenderable): 114 | """Result type for hostgroup.""" 115 | 116 | groupid: str 117 | name: str 118 | hosts: HostList = [] 119 | flags: int 120 | internal: Optional[int] = None # <6.2 121 | 122 | @classmethod 123 | def from_hostgroup(cls, hostgroup: HostGroup) -> HostGroupResult: 124 | return cls( 125 | groupid=hostgroup.groupid, 126 | name=hostgroup.name, 127 | flags=hostgroup.flags, 128 | internal=hostgroup.internal, # <6.2 129 | hosts=hostgroup.hosts, 130 | ) 131 | 132 | # LEGACY 133 | # Mimicks old behavior by also writing the string representation of the 134 | # flags and internal fields to the serialized output. 135 | @computed_field 136 | @property 137 | def flags_str(self) -> str: 138 | return HostgroupFlag.string_from_value(self.flags, with_code=False) 139 | 140 | # VERSION: 6.0 141 | # Internal groups are not a thing in Zabbix >=6.2 142 | @computed_field 143 | @property 144 | def type(self) -> str: 145 | return HostgroupType.string_from_value(self.internal, with_code=True) 146 | 147 | def __cols_rows__(self) -> ColsRowsType: 148 | cols = ["ID", "Name", "Flag", "Hosts"] 149 | rows: RowsType = [ 150 | [ 151 | self.groupid, 152 | self.name, 153 | self.flags_str, 154 | ", ".join(host.host for host in self.hosts), 155 | ] 156 | ] 157 | # VERSION: 6.0 158 | if self.zabbix_version.release < (6, 2): 159 | cols.insert(3, "Type") 160 | t = HostgroupType.string_from_value(self.internal, with_code=False) 161 | rows[0].insert(3, t) # without code in table 162 | return cols, rows 163 | 164 | 165 | class HostGroupPermissions(TableRenderable): 166 | """Result type for hostgroup permissions.""" 167 | 168 | groupid: str 169 | name: str 170 | permissions: list[str] 171 | 172 | def __cols_rows__(self) -> ColsRowsType: 173 | cols = ["GroupID", "Name", "Permissions"] 174 | rows: RowsType = [[self.groupid, self.name, "\n".join(self.permissions)]] 175 | return cols, rows 176 | -------------------------------------------------------------------------------- /zabbix_cli/commands/results/item.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | from typing import Optional 5 | 6 | from pydantic import Field 7 | from pydantic import computed_field 8 | 9 | from zabbix_cli.models import TableRenderable 10 | from zabbix_cli.pyzabbix.types import Item 11 | 12 | if TYPE_CHECKING: 13 | from zabbix_cli.models import ColsRowsType 14 | from zabbix_cli.models import RowsType 15 | 16 | 17 | class UngroupedItem(TableRenderable): 18 | itemid: str 19 | name: Optional[str] 20 | key: Optional[str] 21 | lastvalue: Optional[str] 22 | host: Optional[str] 23 | 24 | @classmethod 25 | def from_item(cls, item: Item) -> UngroupedItem: 26 | return cls( 27 | itemid=item.itemid, 28 | name=item.name, 29 | key=item.key, 30 | lastvalue=item.lastvalue, 31 | host=item.hosts[0].host if item.hosts else None, 32 | ) 33 | 34 | def __cols_rows__(self) -> ColsRowsType: 35 | cols = ["Item ID", "Name", "Key", "Last value", "Host"] 36 | rows: RowsType = [ 37 | [ 38 | self.itemid, 39 | self.name or "", 40 | self.key or "", 41 | self.lastvalue or "", 42 | self.host or "", 43 | ] 44 | ] 45 | return cols, rows 46 | 47 | 48 | class ItemResult(Item): 49 | """Alternate rendering of Item.""" 50 | 51 | grouped: bool = Field(False, exclude=True) 52 | 53 | @classmethod 54 | def from_item(cls, item: Item) -> ItemResult: 55 | return cls.model_validate(item, from_attributes=True) 56 | 57 | @computed_field 58 | @property 59 | def host(self) -> str: 60 | """LEGACY: serialize list of hosts as newline-delimited string.""" 61 | return "\n".join(h.host for h in self.hosts) 62 | 63 | def __cols_rows__(self) -> ColsRowsType: 64 | # As long as we include the "host" computed field, we need to 65 | # override the __cols_rows__ method. 66 | cols = ["Name", "Key", "Last value", "Hosts"] 67 | rows: RowsType = [ 68 | [ 69 | self.name or "", 70 | self.key or "", 71 | self.lastvalue or "", 72 | "\n".join(h.host for h in self.hosts), 73 | ], 74 | ] 75 | if self.grouped: 76 | cols.insert(0, "Item ID") 77 | rows[0].insert(0, self.itemid) 78 | return cols, rows 79 | 80 | 81 | def group_items(items: list[Item]) -> list[ItemResult]: 82 | """Group items by key+lastvalue. 83 | 84 | Keeps first item for each key+lastvalue pair, and adds hosts from 85 | duplicate items to the first item. 86 | 87 | 88 | Example: 89 | ```py 90 | # Given the following items: 91 | >>> items = [ 92 | Item(itemid="1", key="foo", lastvalue="bar", hosts=[Host(hostid="1")]), 93 | Item(itemid="2", key="foo", lastvalue="bar", hosts=[Host(hostid="2")]), 94 | Item(itemid="3", key="baz", lastvalue="baz", hosts=[Host(hostid="3")]), 95 | Item(itemid="4", key="baz", lastvalue="baz", hosts=[Host(hostid="4")]), 96 | ] 97 | >>> group_items(items) 98 | [ 99 | Item(itemid="1", key="foo", lastvalue="bar", hosts=[Host(hostid="1"), Host(hostid="2")]), 100 | Item(itemid="3", key="baz", lastvalue="baz", hosts=[Host(hostid="3"), Host(hostid="4")]), 101 | ] 102 | # Hosts from items 2 and 4 were added to item 1 and 3, respectively. 103 | ``` 104 | """ 105 | from zabbix_cli.commands.results.item import ItemResult 106 | 107 | item_map: dict[str, ItemResult] = {} 108 | 109 | for item in items: 110 | if not item.name or not item.lastvalue or not item.key or not item.hosts: 111 | continue 112 | key = item.key + item.lastvalue 113 | for host in item.hosts: 114 | if key in item_map: 115 | item_map[key].hosts.append(host) 116 | else: 117 | res = ItemResult.from_item(item) 118 | res.grouped = True 119 | item_map[key] = res 120 | return list(item_map.values()) 121 | -------------------------------------------------------------------------------- /zabbix_cli/commands/results/macro.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | from typing import Any 5 | from typing import Optional 6 | 7 | from pydantic import Field 8 | from pydantic import model_serializer 9 | from typing_extensions import Self 10 | 11 | from zabbix_cli.models import TableRenderable 12 | from zabbix_cli.pyzabbix.enums import MacroAutomatic 13 | from zabbix_cli.pyzabbix.types import Macro 14 | 15 | if TYPE_CHECKING: 16 | from zabbix_cli.models import ColsRowsType 17 | from zabbix_cli.models import RowsType 18 | 19 | 20 | class ShowHostUserMacrosResult(TableRenderable): 21 | hostmacroid: str 22 | macro: str 23 | value: Optional[str] = None 24 | type: str 25 | description: Optional[str] = None 26 | hostid: str 27 | automatic: Optional[int] = None 28 | 29 | @classmethod 30 | def from_result(cls, macro: Macro) -> Self: 31 | return cls( 32 | hostmacroid=macro.hostmacroid, 33 | macro=macro.macro, 34 | value=macro.value, 35 | type=macro.type_str, 36 | description=macro.description, 37 | hostid=macro.hostid, 38 | automatic=macro.automatic, 39 | ) 40 | 41 | def __cols_rows__(self) -> ColsRowsType: 42 | cols = [ 43 | "Macro ID", 44 | "Macro", 45 | "Value", 46 | "Type", 47 | "Description", 48 | "Host ID", 49 | "Automatic", 50 | ] 51 | rows: RowsType = [ 52 | [ 53 | self.hostmacroid, 54 | self.macro, 55 | str(self.value), 56 | self.type, 57 | self.description or "", 58 | self.hostid, 59 | MacroAutomatic.string_from_value(self.automatic), 60 | ] 61 | ] 62 | return cols, rows 63 | 64 | 65 | class MacroHostListV2(TableRenderable): 66 | macro: Macro 67 | 68 | def __cols_rows__(self) -> ColsRowsType: 69 | rows: RowsType = [ 70 | [self.macro.macro, str(self.macro.value), host.hostid, host.host] 71 | for host in self.macro.hosts 72 | ] 73 | return ["Macro", "Value", "HostID", "Host"], rows 74 | 75 | @model_serializer() 76 | def model_ser(self) -> dict[str, Any]: 77 | if not self.macro.hosts: 78 | return {} # match V2 output 79 | return { 80 | "macro": self.macro.macro, 81 | "value": self.macro.value, 82 | "hostid": self.macro.hosts[0].hostid, 83 | "host": self.macro.hosts[0].host, 84 | } 85 | 86 | 87 | class MacroHostListV3(TableRenderable): 88 | macro: Macro 89 | 90 | def __cols_rows__(self) -> ColsRowsType: 91 | rows: RowsType = [ 92 | [host.hostid, host.host, self.macro.macro, str(self.macro.value)] 93 | for host in self.macro.hosts 94 | ] 95 | return ["Host ID", "Host", "Macro", "Value"], rows 96 | 97 | 98 | class GlobalMacroResult(TableRenderable): 99 | """Result of `define_global_macro` command.""" 100 | 101 | globalmacroid: str = Field(json_schema_extra={"header": "Global Macro ID"}) 102 | macro: str 103 | value: Optional[str] = None # for usermacro.get calls 104 | 105 | 106 | class ShowUsermacroTemplateListResult(TableRenderable): 107 | macro: str 108 | value: Optional[str] = None 109 | templateid: str 110 | template: str 111 | 112 | def __cols__(self) -> list[str]: 113 | return ["Macro", "Value", "Template ID", "Template"] 114 | -------------------------------------------------------------------------------- /zabbix_cli/commands/results/maintenance.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | from typing import Any 5 | from typing import Optional 6 | 7 | from pydantic import Field 8 | from pydantic import computed_field 9 | from pydantic import field_validator 10 | from typing_extensions import Literal 11 | 12 | from zabbix_cli.models import ColsRowsType 13 | from zabbix_cli.models import TableRenderable 14 | from zabbix_cli.pyzabbix.enums import MaintenanceType 15 | from zabbix_cli.pyzabbix.types import TimePeriod 16 | 17 | 18 | class CreateMaintenanceDefinitionResult(TableRenderable): 19 | """Result type for `create_maintenance_definition` command.""" 20 | 21 | maintenance_id: str 22 | 23 | 24 | class ShowMaintenancePeriodsResult(TableRenderable): 25 | maintenanceid: str = Field(title="Maintenance ID") 26 | name: str 27 | timeperiods: list[TimePeriod] 28 | hosts: list[str] 29 | groups: list[str] 30 | 31 | 32 | class ShowMaintenanceDefinitionsResult(TableRenderable): 33 | """Result type for `show_maintenance_definitions` command.""" 34 | 35 | maintenanceid: str 36 | name: str 37 | type: Optional[int] 38 | active_till: datetime 39 | description: Optional[str] 40 | hosts: list[str] 41 | groups: list[str] 42 | 43 | @computed_field 44 | @property 45 | def state(self) -> Literal["Active", "Expired"]: 46 | now_time = datetime.now(tz=self.active_till.tzinfo) 47 | if self.active_till > now_time: 48 | return "Active" 49 | return "Expired" 50 | 51 | @computed_field 52 | @property 53 | def maintenance_type(self) -> str: 54 | return MaintenanceType.string_from_value(self.type) 55 | 56 | @field_validator("active_till", mode="before") 57 | @classmethod 58 | def validate_active_till(cls, v: Any) -> datetime: 59 | if v is None: 60 | return datetime.now() 61 | return v 62 | 63 | @property 64 | def state_str(self) -> str: 65 | if self.state == "Active": 66 | color = "green" 67 | else: 68 | color = "red" 69 | return f"[{color}]{self.state}[/]" 70 | 71 | @property 72 | def maintenance_type_str(self) -> str: 73 | # FIXME: This is very brittle! We are beholden to self.maintenance_type... 74 | if "With DC" in self.maintenance_type: 75 | color = "green" 76 | else: 77 | color = "red" 78 | return f"[{color}]{self.maintenance_type}[/]" 79 | 80 | def __cols_rows__(self) -> ColsRowsType: 81 | return ( 82 | [ 83 | "ID", 84 | "Name", 85 | "Type", 86 | "Active till", 87 | "Hosts", 88 | "Host groups", 89 | "State", 90 | "Description", 91 | ], 92 | [ 93 | [ 94 | self.maintenanceid, 95 | self.name, 96 | self.maintenance_type_str, 97 | self.active_till.strftime("%Y-%m-%d %H:%M"), 98 | ", ".join(self.hosts), 99 | ", ".join(self.groups), 100 | self.state_str, 101 | self.description or "", 102 | ] 103 | ], 104 | ) 105 | -------------------------------------------------------------------------------- /zabbix_cli/commands/results/problem.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Optional 4 | 5 | from zabbix_cli.models import TableRenderable 6 | 7 | 8 | class AcknowledgeEventResult(TableRenderable): 9 | """Result type for `acknowledge_event` command.""" 10 | 11 | event_ids: list[str] = [] 12 | close: bool = False 13 | message: Optional[str] = None 14 | 15 | 16 | class AcknowledgeTriggerLastEventResult(AcknowledgeEventResult): 17 | """Result type for `acknowledge_trigger_last_event` command.""" 18 | 19 | trigger_ids: list[str] = [] 20 | -------------------------------------------------------------------------------- /zabbix_cli/commands/results/template.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Literal 4 | from typing import Union 5 | 6 | from zabbix_cli.models import TableRenderable 7 | from zabbix_cli.pyzabbix.types import Host 8 | from zabbix_cli.pyzabbix.types import HostGroup 9 | from zabbix_cli.pyzabbix.types import Template 10 | from zabbix_cli.pyzabbix.types import TemplateGroup 11 | 12 | 13 | class LinkTemplateToHostResult(TableRenderable): 14 | host: str 15 | templates: list[str] 16 | action: str 17 | 18 | @classmethod 19 | def from_result( 20 | cls, 21 | templates: list[Template], 22 | host: Host, 23 | action: str, 24 | ) -> LinkTemplateToHostResult: 25 | to_link: set[str] = set() # names of templates to link 26 | for t in templates: 27 | for h in t.hosts: 28 | if h.host == host.host: 29 | break 30 | else: 31 | to_link.add(t.host) 32 | return cls( 33 | host=host.host, 34 | templates=sorted(to_link), 35 | action=action, 36 | ) 37 | 38 | 39 | class UnlinkTemplateFromHostResult(TableRenderable): 40 | host: str 41 | templates: list[str] 42 | action: str 43 | 44 | @classmethod 45 | def from_result( 46 | cls, 47 | templates: list[Template], 48 | host: Host, 49 | action: str, 50 | ) -> UnlinkTemplateFromHostResult: 51 | """Only show templates that are actually unlinked.""" 52 | to_remove: set[str] = set() 53 | for t in templates: 54 | for h in t.hosts: 55 | if h.host == host.host: 56 | to_remove.add(t.host) # name of template 57 | break 58 | return cls( 59 | host=host.host, 60 | templates=list(to_remove), 61 | action=action, 62 | ) 63 | 64 | 65 | class LinkTemplateResult(TableRenderable): 66 | """Result type for (un)linking templates to templates.""" 67 | 68 | source: list[str] 69 | destination: list[str] 70 | action: str 71 | 72 | @classmethod 73 | def from_result( 74 | cls, 75 | source: list[Template], 76 | destination: list[Template], 77 | action: Literal["Link", "Unlink", "Unlink and clear"], 78 | ) -> LinkTemplateResult: 79 | return cls( 80 | source=[t.host for t in source], 81 | destination=[t.host for t in destination], 82 | action=action, 83 | ) 84 | 85 | 86 | class TemplateGroupResult(TableRenderable): 87 | templates: list[str] 88 | groups: list[str] 89 | 90 | @classmethod 91 | def from_result( 92 | cls, 93 | templates: list[Template], 94 | groups: Union[list[TemplateGroup], list[HostGroup]], 95 | ) -> TemplateGroupResult: 96 | return cls( 97 | templates=[t.host for t in templates], 98 | groups=[h.name for h in groups], 99 | ) 100 | 101 | 102 | class RemoveTemplateFromGroupResult(TableRenderable): 103 | group: str 104 | templates: list[str] 105 | 106 | @classmethod 107 | def from_result( 108 | cls, 109 | templates: list[Template], 110 | group: Union[TemplateGroup, HostGroup], 111 | ) -> RemoveTemplateFromGroupResult: 112 | to_remove: set[str] = set() 113 | for template in group.templates: 114 | for t in templates: 115 | if t.host == template.host: 116 | to_remove.add(t.host) 117 | break 118 | return cls( 119 | templates=list(to_remove), 120 | group=group.name, 121 | ) 122 | -------------------------------------------------------------------------------- /zabbix_cli/commands/results/templategroup.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | from typing import Any 5 | from typing import Union 6 | 7 | from pydantic import Field 8 | from pydantic import computed_field 9 | from pydantic import field_serializer 10 | 11 | from zabbix_cli.models import TableRenderable 12 | from zabbix_cli.pyzabbix.types import HostGroup 13 | from zabbix_cli.pyzabbix.types import Template 14 | from zabbix_cli.pyzabbix.types import TemplateGroup 15 | 16 | if TYPE_CHECKING: 17 | from zabbix_cli.models import ColsRowsType 18 | from zabbix_cli.models import RowsType 19 | 20 | 21 | class ShowTemplateGroupResult(TableRenderable): 22 | """Result type for templategroup.""" 23 | 24 | groupid: str = Field(..., json_schema_extra={"header": "Group ID"}) 25 | name: str 26 | templates: list[Template] = [] 27 | show_templates: bool = Field(True, exclude=True) 28 | 29 | @classmethod 30 | def from_result( 31 | cls, group: Union[HostGroup, TemplateGroup], show_templates: bool 32 | ) -> ShowTemplateGroupResult: 33 | return cls( 34 | groupid=group.groupid, 35 | name=group.name, 36 | templates=group.templates, 37 | show_templates=show_templates, 38 | ) 39 | 40 | @computed_field 41 | @property 42 | def template_count(self) -> int: 43 | return len(self.templates) 44 | 45 | @field_serializer("templates") 46 | def templates_serializer(self, value: list[Template]) -> list[dict[str, Any]]: 47 | if self.show_templates: 48 | return [t.model_dump(mode="json") for t in value] 49 | return [] 50 | 51 | def __rows__(self) -> RowsType: 52 | tpls = self.templates if self.show_templates else [] 53 | return [ 54 | [ 55 | self.groupid, 56 | self.name, 57 | "\n".join(str(t.host) for t in sorted(tpls, key=lambda t: t.host)), 58 | str(self.template_count), 59 | ] 60 | ] 61 | 62 | 63 | class ExtendTemplateGroupResult(TableRenderable): 64 | source: str 65 | destination: list[str] 66 | templates: list[str] 67 | 68 | @classmethod 69 | def from_result( 70 | cls, 71 | src_group: Union[HostGroup, TemplateGroup], 72 | dest_group: Union[list[HostGroup], list[TemplateGroup]], 73 | templates: list[Template], 74 | ) -> ExtendTemplateGroupResult: 75 | return cls( 76 | source=src_group.name, 77 | destination=[grp.name for grp in dest_group], 78 | templates=[t.host for t in templates], 79 | ) 80 | 81 | 82 | class MoveTemplatesResult(TableRenderable): 83 | """Result type for `move_templates` command.""" 84 | 85 | source: str 86 | destination: str 87 | templates: list[str] 88 | 89 | @classmethod 90 | def from_result( 91 | cls, 92 | source: Union[HostGroup, TemplateGroup], 93 | destination: Union[HostGroup, TemplateGroup], 94 | ) -> MoveTemplatesResult: 95 | return cls( 96 | source=source.name, 97 | destination=destination.name, 98 | templates=[template.host for template in source.templates], 99 | ) 100 | 101 | def __cols_rows__(self) -> ColsRowsType: 102 | """Only print the template names in the table. 103 | 104 | Source and destination are apparent from the surrounding context. 105 | """ 106 | cols = ["Templates"] 107 | rows: RowsType = [["\n".join(self.templates)]] 108 | return cols, rows 109 | -------------------------------------------------------------------------------- /zabbix_cli/commands/results/user.py: -------------------------------------------------------------------------------- 1 | """User result types.""" 2 | 3 | # NOTE: The user module was one of the first to be written, and thus 4 | # most of the result rendering was implemented in the Pyzabbix models 5 | # themselves instead of as result classes. That is why this module is 6 | # (mostly) empty. 7 | from __future__ import annotations 8 | 9 | from zabbix_cli.models import AggregateResult 10 | from zabbix_cli.models import ColsRowsType 11 | from zabbix_cli.models import ColsType 12 | from zabbix_cli.models import RowsType 13 | from zabbix_cli.models import TableRenderable 14 | from zabbix_cli.pyzabbix.types import Usergroup 15 | from zabbix_cli.pyzabbix.types import UserMedia 16 | 17 | 18 | class CreateNotificationUserResult(TableRenderable): 19 | """Result type for creating a notification user.""" 20 | 21 | username: str 22 | userid: str 23 | media: list[UserMedia] 24 | usergroups: list[Usergroup] 25 | 26 | def __cols_rows__(self) -> ColsRowsType: 27 | cols: ColsType = [ 28 | "User ID", 29 | "Username", 30 | "Media", 31 | "Usergroups", 32 | ] 33 | rows: RowsType = [ 34 | [ 35 | self.userid, 36 | self.username, 37 | AggregateResult(result=self.media).as_table(), 38 | "\n".join([ug.name for ug in self.usergroups]), 39 | ] 40 | ] 41 | return cols, rows 42 | -------------------------------------------------------------------------------- /zabbix_cli/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unioslo/zabbix-cli/89a4eac093114b8d39cdc32bd630d40fabe9affc/zabbix_cli/config/__init__.py -------------------------------------------------------------------------------- /zabbix_cli/config/__main__.py: -------------------------------------------------------------------------------- 1 | # Run the config module with python -m zabbix_cli.config 2 | from __future__ import annotations 3 | 4 | if __name__ == "__main__": 5 | import typer 6 | 7 | from zabbix_cli.config.run import main 8 | 9 | typer.run(main) 10 | -------------------------------------------------------------------------------- /zabbix_cli/config/base.py: -------------------------------------------------------------------------------- 1 | """Base model for all configuration models.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any 6 | 7 | from pydantic import BaseModel as PydanticBaseModel 8 | from pydantic import ConfigDict 9 | from pydantic import PrivateAttr 10 | from pydantic import ValidationInfo 11 | from pydantic import field_validator 12 | from pydantic import model_validator 13 | from typing_extensions import Self 14 | 15 | from zabbix_cli.config.utils import check_deprecated_fields 16 | 17 | 18 | class BaseModel(PydanticBaseModel): 19 | model_config = ConfigDict(validate_assignment=True, extra="ignore") 20 | 21 | _deprecation_checked: bool = PrivateAttr(default=False) 22 | """Has performed a deprecaction check for the fields on the model.""" 23 | 24 | @field_validator("*") 25 | @classmethod 26 | def _conf_bool_validator_compat(cls, v: Any, info: ValidationInfo) -> Any: 27 | """Handles old config files that specified bools as ON/OFF.""" 28 | if not isinstance(v, str): 29 | return v 30 | if v.upper() == "ON": 31 | return True 32 | if v.upper() == "OFF": 33 | return False 34 | return v 35 | 36 | @model_validator(mode="after") 37 | def _check_deprecated_fields(self) -> Self: 38 | """Check for deprecated fields and log warnings.""" 39 | if not self._deprecation_checked: 40 | check_deprecated_fields(self) 41 | self._deprecation_checked = True 42 | return self 43 | -------------------------------------------------------------------------------- /zabbix_cli/config/commands.py: -------------------------------------------------------------------------------- 1 | """Configuration classes for Zabbix CLI commands.""" 2 | 3 | from __future__ import annotations 4 | 5 | from zabbix_cli.config.base import BaseModel 6 | 7 | 8 | class CreateHost(BaseModel): 9 | """Configuration for the `create_host` command.""" 10 | 11 | create_interface: bool = True 12 | -------------------------------------------------------------------------------- /zabbix_cli/config/constants.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from pathlib import Path 5 | from typing import Any 6 | from typing import Callable 7 | from typing import Optional 8 | from typing import Union 9 | 10 | from strenum import StrEnum 11 | 12 | from zabbix_cli.dirs import CONFIG_DIR 13 | from zabbix_cli.dirs import DATA_DIR 14 | from zabbix_cli.dirs import LOGS_DIR 15 | from zabbix_cli.dirs import SITE_CONFIG_DIR 16 | 17 | logger = logging.getLogger("zabbix_cli.config") 18 | 19 | # Config file basename 20 | CONFIG_FILENAME = "zabbix-cli.toml" 21 | DEFAULT_CONFIG_FILE = CONFIG_DIR / CONFIG_FILENAME 22 | 23 | 24 | CONFIG_PRIORITY = ( 25 | Path() / CONFIG_FILENAME, # current directory 26 | DEFAULT_CONFIG_FILE, # local config directory 27 | SITE_CONFIG_DIR / CONFIG_FILENAME, # system config directory 28 | ) 29 | 30 | 31 | # File path defaults 32 | AUTH_TOKEN_FILE = DATA_DIR / ".zabbix-cli_auth_token" 33 | """Path to file containing API session token.""" 34 | 35 | AUTH_FILE = DATA_DIR / ".zabbix-cli_auth" 36 | """Path to file containing user credentials.""" 37 | 38 | SESSION_FILE = DATA_DIR / ".zabbix-cli_session.json" 39 | """Path to JSON file containing API session IDs.""" 40 | 41 | HISTORY_FILE = DATA_DIR / "history" 42 | """Path to file containing REPL history.""" 43 | 44 | LOG_FILE = LOGS_DIR / "zabbix-cli.log" 45 | 46 | 47 | # Environment variable names 48 | class ConfigEnvVars: 49 | API_TOKEN = "ZABBIX_API_TOKEN" 50 | PASSWORD = "ZABBIX_PASSWORD" 51 | URL = "ZABBIX_URL" 52 | USERNAME = "ZABBIX_USERNAME" 53 | 54 | 55 | class OutputFormat(StrEnum): 56 | CSV = "csv" 57 | JSON = "json" 58 | TABLE = "table" 59 | 60 | 61 | class SecretMode(StrEnum): 62 | """Mode for serializing secrets.""" 63 | 64 | HIDE = "hide" 65 | MASK = "masked" 66 | PLAIN = "plain" 67 | 68 | _DEFAULT = MASK 69 | 70 | @classmethod 71 | def from_context( 72 | cls, context: Optional[Union[dict[str, Any], Callable[..., dict[str, Any]]]] 73 | ) -> SecretMode: 74 | """Get the secret mode from a serialization context.""" 75 | if isinstance(context, dict) and (ctx := context.get("secrets")) is not None: 76 | # Support for old-style context values (true/false) 77 | # as well as new context values (enum values) 78 | try: 79 | if isinstance(ctx, SecretMode): 80 | return ctx 81 | elif ctx is True: 82 | return cls.PLAIN 83 | elif ctx is False: 84 | return cls.MASK 85 | else: 86 | return cls(str(ctx).lower()) 87 | except ValueError: 88 | logger.warning( 89 | "Got invalid secret mode from context %s: %s", context, ctx 90 | ) 91 | return cls._DEFAULT 92 | -------------------------------------------------------------------------------- /zabbix_cli/config/run.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import Enum 4 | from pathlib import Path 5 | from typing import Optional 6 | 7 | import typer 8 | 9 | from zabbix_cli.config.model import Config 10 | from zabbix_cli.config.utils import get_config 11 | 12 | 13 | class RunMode(str, Enum): 14 | SHOW = "show" 15 | DEFAULTS = "defaults" 16 | 17 | 18 | def main( 19 | arg: RunMode = typer.Argument(RunMode.DEFAULTS, case_sensitive=False), 20 | filename: Optional[Path] = None, 21 | ): 22 | """Print current or default config to stdout.""" 23 | from zabbix_cli.logs import configure_logging 24 | 25 | configure_logging() 26 | if arg == RunMode.SHOW: 27 | config = get_config(filename) 28 | else: 29 | config = Config.sample_config() 30 | print(config.as_toml()) 31 | -------------------------------------------------------------------------------- /zabbix_cli/dirs.py: -------------------------------------------------------------------------------- 1 | """Defines directories for the application. 2 | 3 | Follows the XDG Base Directory Specification on Linux: 4 | See for other platforms. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import logging 10 | from pathlib import Path 11 | from typing import NamedTuple 12 | 13 | from platformdirs import PlatformDirs 14 | 15 | from zabbix_cli.__about__ import APP_NAME 16 | from zabbix_cli.__about__ import AUTHOR 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | _PLATFORM_DIR = PlatformDirs(APP_NAME, AUTHOR) 22 | 23 | CONFIG_DIR = _PLATFORM_DIR.user_config_path 24 | """Directory for user configuration files.""" 25 | 26 | DATA_DIR = _PLATFORM_DIR.user_data_path 27 | """Directory for user data files.""" 28 | 29 | LOGS_DIR = _PLATFORM_DIR.user_log_path 30 | """Directory to store user log files.""" 31 | 32 | SITE_CONFIG_DIR = _PLATFORM_DIR.site_config_path 33 | """Directory for site-wide configuration files, i.e. `/etc/xdg/zabbix-cli`.""" 34 | 35 | EXPORT_DIR = DATA_DIR / "exports" 36 | """Directory to store exported data.""" 37 | 38 | 39 | class Directory(NamedTuple): 40 | name: str 41 | path: Path 42 | required: bool = False 43 | create: bool = True 44 | """Do not log/print on failure to create directory.""" 45 | 46 | 47 | DIRS = [ 48 | Directory("Config", CONFIG_DIR), 49 | Directory("Data", DATA_DIR), 50 | Directory("Logs", LOGS_DIR), 51 | # Don't create site config directory by default (root-only) 52 | Directory("Site Config", SITE_CONFIG_DIR, create=False), 53 | # Exports directory is created on demand 54 | Directory("Exports", EXPORT_DIR, create=False), 55 | ] 56 | 57 | 58 | def init_directories() -> None: 59 | """Create required directories for the application to function.""" 60 | from .output.console import error 61 | from .output.console import exit_err 62 | 63 | for directory in DIRS: 64 | if directory.path.exists() or not directory.create: 65 | logger.debug( 66 | "Skipping creating directory '%s'. Exists: %s", 67 | directory.path, 68 | directory.path.exists(), 69 | ) 70 | continue 71 | try: 72 | directory.path.mkdir(parents=True) 73 | except Exception as e: 74 | message = ( 75 | f"Failed to create {directory.name} directory {directory.path}: {e}" 76 | ) 77 | if directory.required: 78 | exit_err(message, exc_info=True) 79 | else: 80 | error(message, exc_info=True) 81 | -------------------------------------------------------------------------------- /zabbix_cli/output/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /zabbix_cli/output/formatting/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /zabbix_cli/output/formatting/bytes.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pydantic import ByteSize 4 | 5 | from .constants import NONE_STR 6 | 7 | 8 | def bytesize_str(b: int | None, *, decimal: bool = False) -> str: 9 | if b is None or b < 0: 10 | return NONE_STR 11 | return ByteSize(b).human_readable(decimal=decimal) 12 | -------------------------------------------------------------------------------- /zabbix_cli/output/formatting/constants.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | NONE_STR = "None" 4 | TRUE_STR = "True" 5 | FALSE_STR = "False" 6 | -------------------------------------------------------------------------------- /zabbix_cli/output/formatting/grammar.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | def _pluralize_word(word: str, count: int) -> str: 5 | if count == 1: 6 | return word 7 | if word.endswith("y"): 8 | return word[:-1] + "ies" 9 | return word + "s" 10 | 11 | 12 | def pluralize(word: str, count: int, *, with_count: bool = True) -> str: 13 | """Pluralize a word based on a count. 14 | 15 | Examples: 16 | >>> from zabbix_cli.output.formatting.grammar import pluralize as p 17 | >>> p("apple", 1) 18 | '1 apple' 19 | >>> p("apple", 2) 20 | '2 apples' 21 | >>> p("category", 1) 22 | '1 category' 23 | >>> p("category", 2) 24 | '2 categories' 25 | >>> p("category", 0) 26 | '0 categories' 27 | >>> p("category", 0, with_count=False) # see pluralize_no_count 28 | 'categories' 29 | """ 30 | if with_count: 31 | return f"{count} {_pluralize_word(word, count)}" 32 | return _pluralize_word(word, count) 33 | 34 | 35 | def pluralize_no_count(word: str, count: int) -> str: 36 | """Pluralize a word without a count prepended to the pluralized word. 37 | 38 | Shortcut for `pluralize(word, count, with_count=False)`. 39 | 40 | Examples: 41 | >>> from zabbix_cli.output.formatting.grammar import pluralize_no_count as pnc 42 | >>> pnc("apple", 1) 43 | 'apple' 44 | >>> pnc("apple", 2) 45 | 'apples' 46 | >>> pnc("category", 1) 47 | 'category' 48 | >>> pnc("category", 2) 49 | 'categories' 50 | """ 51 | return _pluralize_word(word, count) 52 | -------------------------------------------------------------------------------- /zabbix_cli/output/formatting/path.py: -------------------------------------------------------------------------------- 1 | """Control the formatting of console output.""" 2 | 3 | from __future__ import annotations 4 | 5 | from pathlib import Path 6 | 7 | 8 | def path_link(path: Path, *, absolute: bool = True) -> str: 9 | """Return a link to a path.""" 10 | abspath = path.resolve().absolute() 11 | if absolute: 12 | path_str = str(abspath) 13 | else: 14 | path_str = str(path) 15 | return f"[link=file://{abspath}]{path_str}[/link]" 16 | -------------------------------------------------------------------------------- /zabbix_cli/output/render.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from contextlib import nullcontext 5 | from typing import TYPE_CHECKING 6 | from typing import Any 7 | 8 | import typer 9 | 10 | from zabbix_cli.output.console import console 11 | from zabbix_cli.output.console import error 12 | from zabbix_cli.output.console import success 13 | from zabbix_cli.state import get_state 14 | 15 | if TYPE_CHECKING: 16 | from pydantic import BaseModel 17 | 18 | from zabbix_cli.models import BaseResult 19 | from zabbix_cli.models import TableRenderable 20 | 21 | 22 | def wrap_result(result: BaseModel) -> BaseResult: 23 | """Wraps a BaseModel instance in a Result object so that it receives 24 | `return_code`, `errors`, and `message` fields, with the original object 25 | is available as `result`. 26 | 27 | Does nothing if the function argument is already a BaseResult instance. 28 | """ 29 | from zabbix_cli.models import BaseResult 30 | from zabbix_cli.models import Result 31 | 32 | if isinstance(result, BaseResult): 33 | return result 34 | # TODO: handle AggregateResult? (8 months later: What did I mean by this?) 35 | return Result(result=result) 36 | 37 | 38 | def render_result( 39 | result: TableRenderable, 40 | ctx: typer.Context | None = None, 41 | **kwargs: Any, 42 | ) -> None: 43 | """Render the result of a command stdout or file. 44 | 45 | Parameters 46 | ---------- 47 | result: TableRenderable, 48 | The result of a command. All commands produce a TableRenderable (BaseModel). 49 | ctx : typer.Context, optional 50 | The typer context from the command invocation, by default None 51 | **kwargs 52 | Additional keyword arguments to pass to the render function. 53 | """ 54 | from zabbix_cli.config.constants import OutputFormat 55 | 56 | # Short form aliases 57 | state = get_state() 58 | fmt = state.config.app.output.format 59 | # paging = state.config.output.paging 60 | paging = False # TODO: implement 61 | 62 | ctx_manager = console.pager() if paging else nullcontext() 63 | with ctx_manager: 64 | if fmt == OutputFormat.JSON: 65 | if state.config.app.legacy_json_format: 66 | render_json_legacy(result, ctx, **kwargs) 67 | else: 68 | render_json(result, ctx, **kwargs) 69 | elif fmt == OutputFormat.TABLE: 70 | render_table(result, ctx, **kwargs) 71 | # TODO: implement CSV 72 | else: 73 | raise ValueError(f"Unknown output format {fmt!r}.") 74 | 75 | 76 | def render_table( 77 | result: TableRenderable, ctx: typer.Context | None = None, **kwargs: Any 78 | ) -> None: 79 | """Render the result of a command as a table if possible. 80 | If result contains a message, print success message instead. 81 | """ 82 | # TODO: be able to print message _AND_ table 83 | # The Result/TableRenderable dichotomy is a bit of a mess 84 | from zabbix_cli.models import Result 85 | from zabbix_cli.models import ReturnCode 86 | 87 | if isinstance(result, Result) and result.message: 88 | if result.return_code == ReturnCode.ERROR: 89 | error(result.message) 90 | else: 91 | success(result.message) 92 | else: 93 | tbl = result.as_table() 94 | if not tbl.rows: 95 | if not result.empty_ok: 96 | console.print("No results found.") 97 | else: 98 | console.print(tbl) 99 | 100 | 101 | def render_json( 102 | result: TableRenderable, 103 | ctx: typer.Context | None = None, 104 | **kwargs: Any, 105 | ) -> None: 106 | """Render the result of a command as JSON.""" 107 | from zabbix_cli.models import ReturnCode 108 | 109 | result = wrap_result(result) 110 | o_json = result.model_dump_json(indent=2, by_alias=True) 111 | console.print_json(o_json, indent=2, sort_keys=False) 112 | if result.message: 113 | if result.return_code == ReturnCode.ERROR: 114 | error(result.message) 115 | else: 116 | success(result.message) 117 | 118 | 119 | def render_json_legacy( 120 | result: TableRenderable, 121 | ctx: typer.Context | None = None, 122 | **kwargs: Any, 123 | ) -> None: 124 | """Render the result of a command as JSON (legacy V2 format). 125 | 126 | Result is always a dict with numeric string keys. 127 | 128 | Note: 129 | ---- 130 | This function is very hacky, and will inevitably contain a number of band-aid 131 | fixes to enable 1:1 compatibility with the legacy V2 JSON format. 132 | We should try to move away from this format ASAP, so we can remove 133 | this function and all its hacks. 134 | """ 135 | from zabbix_cli.models import Result 136 | 137 | # If we have a message, it should not be indexed 138 | # NOTE: do we have a more accurate heuristic for this? 139 | if isinstance(result, Result) and result.message: 140 | j = result.model_dump_json(indent=2) 141 | else: 142 | from zabbix_cli.models import AggregateResult 143 | 144 | jdict: dict[str, Any] = {} # always a dict in legacy mode 145 | res = result.model_dump(mode="json", by_alias=True) 146 | if isinstance(result, AggregateResult): 147 | py_result = res.get("result", []) 148 | else: 149 | py_result = [res] 150 | 151 | for idx, item in enumerate(py_result): 152 | jdict[str(idx)] = item 153 | j = json.dumps(jdict, indent=2) 154 | console.print_json(j, indent=2, sort_keys=False) 155 | -------------------------------------------------------------------------------- /zabbix_cli/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unioslo/zabbix-cli/89a4eac093114b8d39cdc32bd630d40fabe9affc/zabbix_cli/py.typed -------------------------------------------------------------------------------- /zabbix_cli/pyzabbix/__init__.py: -------------------------------------------------------------------------------- 1 | """This module is based on PyZabbix (https://github.com/lukecyca/pyzabbix), 2 | which is licensed under the GNU Lesser General Public License (LGPL) according 3 | to its PyPI metadata. 4 | 5 | It is unclear which version of PyZabbix was vendored into Zabbix-CLI originally, 6 | but we can assume it's not a version later than 0.7.4, which was the last version 7 | available on PyPI before the majority of the code was vendored, as evidenced by 8 | this git blame: https://github.com/unioslo/zabbix-cli/blame/2.3.2/zabbix_cli/pyzabbix.py 9 | 10 | We assume that the copyright years of the original PyZabbix code are from 2013-2015 11 | for that reason. The source code repository contains no LICENSE file, even 12 | though its metadata states that it is LGPL-licensed. 13 | 14 | An abbreviated version of the LGPL-3.0 license text is included below: 15 | 16 | Copyright (C) 2013-2015 PyZabbix Contributors 17 | Modified work Copyright (C) 2022-2024 University of Oslo 18 | 19 | This library is free software: you can redistribute it and/or modify 20 | it under the terms of the GNU Lesser General Public License as published by 21 | the Free Software Foundation, either version 3 of the License, or 22 | (at your option) any later version. 23 | 24 | This library is distributed in the hope that it will be useful, 25 | but WITHOUT ANY WARRANTY; without even the implied warranty of 26 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 27 | GNU Lesser General Public License for more details. 28 | 29 | You should have received a copy of the GNU Lesser General Public License 30 | along with this library. If not, see . 31 | 32 | Additional Notices: 33 | - This code was originally vendored by Zabbix-CLI from PyZabbix 34 | - Modifications have been made to the original PyZabbix code to adapt it 35 | for use in this project. It is _very_ different, and it's unclear if 36 | we should even call it PyZabbix anymore. It's more like a fork. 37 | - The original source code can be found at: https://github.com/lukecyca/pyzabbix 38 | """ 39 | 40 | from __future__ import annotations 41 | -------------------------------------------------------------------------------- /zabbix_cli/pyzabbix/compat.py: -------------------------------------------------------------------------------- 1 | """Compatibility functions to support different Zabbix API versions.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Literal 6 | 7 | from packaging.version import Version 8 | 9 | # TODO (pederhan): rewrite these functions as some sort of declarative data 10 | # structure that can be used to determine correct parameters based on version 11 | # if we end up with a lot of these functions. For now, this is fine. 12 | # OR we could turn it into a mixin class? 13 | 14 | # Compatibility methods for Zabbix API objects properties and method parameters. 15 | # Returns the appropriate property name for the given Zabbix version. 16 | # 17 | # FORMAT: _ 18 | # EXAMPLE: user_name() (User object, name property) 19 | # 20 | # NOTE: All functions follow the same pattern: 21 | # Early return if the version is older than the version where the property 22 | # was deprecated, otherwise return the new property name as the default. 23 | 24 | 25 | def host_proxyid(version: Version) -> Literal["proxy_hostid", "proxyid"]: 26 | # https://support.zabbix.com/browse/ZBXNEXT-8500 27 | # https://www.zabbix.com/documentation/7.0/en/manual/api/changes#host 28 | if version.release < (7, 0, 0): 29 | return "proxy_hostid" 30 | return "proxyid" 31 | 32 | 33 | def host_available(version: Version) -> Literal["available", "active_available"]: 34 | # TODO: find out why this was changed and what it signifies 35 | # NO URL YET 36 | if version.release < (6, 4, 0): 37 | return "available" # unsupported in < 6.4.0 38 | return "active_available" 39 | 40 | 41 | def login_user_name(version: Version) -> Literal["user", "username"]: 42 | # https://support.zabbix.com/browse/ZBXNEXT-8085 43 | # Deprecated in 5.4.0, removed in 6.4.0 44 | # login uses different parameter names than the User object before 6.4 45 | # From 6.4 and onwards, login and user. use the same parameter names 46 | # See: user_name 47 | if version.release < (5, 4, 0): 48 | return "user" 49 | return "username" 50 | 51 | 52 | def proxy_name(version: Version) -> Literal["host", "name"]: 53 | # https://support.zabbix.com/browse/ZBXNEXT-8500 54 | # https://www.zabbix.com/documentation/7.0/en/manual/api/changes#proxy 55 | if version.release < (7, 0, 0): 56 | return "host" 57 | return "name" 58 | 59 | 60 | def role_id(version: Version) -> Literal["roleid", "type"]: 61 | # https://support.zabbix.com/browse/ZBXNEXT-6148 62 | # https://www.zabbix.com/documentation/5.2/en/manual/api/changes_5.0_-_5.2#role 63 | if version.release < (5, 2, 0): 64 | return "type" 65 | return "roleid" 66 | 67 | 68 | def user_name(version: Version) -> Literal["alias", "username"]: 69 | # https://support.zabbix.com/browse/ZBXNEXT-8085 70 | # Deprecated in 5.4, removed in 6.4 71 | # However, historically we have used "alias" as the parameter name 72 | # pre-6.0.0, so we maintain that behavior here 73 | if version.release < (6, 0, 0): 74 | return "alias" 75 | return "username" 76 | 77 | 78 | def user_medias(version: Version) -> Literal["user_medias", "medias"]: 79 | # https://support.zabbix.com/browse/ZBX-17955 80 | # Deprecated in 5.2, removed in 6.4 81 | if version.release < (5, 2, 0): 82 | return "user_medias" 83 | return "medias" 84 | 85 | 86 | def usergroup_hostgroup_rights( 87 | version: Version, 88 | ) -> Literal["rights", "hostgroup_rights"]: 89 | # https://support.zabbix.com/browse/ZBXNEXT-2592 90 | # https://www.zabbix.com/documentation/6.2/en/manual/api/changes_6.0_-_6.2 91 | # Deprecated in 6.2 92 | if version.release < (6, 2, 0): 93 | return "rights" 94 | return "hostgroup_rights" 95 | 96 | 97 | # NOTE: can we remove this function? Or are we planning on using it to 98 | # assign rights for templates? 99 | def usergroup_templategroup_rights( 100 | version: Version, 101 | ) -> Literal["rights", "templategroup_rights"]: 102 | # https://support.zabbix.com/browse/ZBXNEXT-2592 103 | # https://www.zabbix.com/documentation/6.2/en/manual/api/changes_6.0_-_6.2 104 | # Deprecated in 6.2 105 | if version.release < (6, 2, 0): 106 | return "rights" 107 | return "templategroup_rights" 108 | 109 | 110 | ### API params 111 | # API parameter functions are in the following format: 112 | # param___ 113 | # So to get the "groups" parameter for the "host.get" method, you would call: 114 | # param_host_get_groups() 115 | 116 | 117 | def param_host_get_groups( 118 | version: Version, 119 | ) -> Literal["selectHostGroups", "selectGroups"]: 120 | # https://support.zabbix.com/browse/ZBXNEXT-2592 121 | # hhttps://www.zabbix.com/documentation/6.2/en/manual/api/changes_6.0_-_6.2#host 122 | if version.release < (6, 2, 0): 123 | return "selectGroups" 124 | return "selectHostGroups" 125 | 126 | 127 | def param_maintenance_create_groupids( 128 | version: Version, 129 | ) -> Literal["groupids", "groups"]: 130 | # https://support.zabbix.com/browse/ZBXNEXT-2592 131 | # https://www.zabbix.com/documentation/6.2/en/manual/api/changes_6.0_-_6.2#host 132 | if version.release < (6, 2, 0): 133 | return "groups" 134 | return "groupids" 135 | -------------------------------------------------------------------------------- /zabbix_cli/pyzabbix/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import random 4 | import re 5 | from typing import TYPE_CHECKING 6 | from typing import Optional 7 | 8 | from zabbix_cli.exceptions import ZabbixAPICallError 9 | from zabbix_cli.exceptions import ZabbixNotFoundError 10 | from zabbix_cli.pyzabbix.client import ZabbixAPI 11 | 12 | if TYPE_CHECKING: 13 | from zabbix_cli.pyzabbix.types import Proxy 14 | 15 | 16 | def get_random_proxy(client: ZabbixAPI, pattern: Optional[str] = None) -> Proxy: 17 | """Fetch a random proxy, optionally matching a regex pattern.""" 18 | proxies = client.get_proxies() 19 | if not proxies: 20 | raise ZabbixNotFoundError("No proxies found") 21 | if pattern: 22 | try: 23 | re_pattern = re.compile(pattern) 24 | except re.error as e: 25 | raise ZabbixAPICallError(f"Invalid proxy regex pattern: {pattern!r}") from e 26 | proxies = [proxy for proxy in proxies if re_pattern.match(proxy.name)] 27 | if not proxies: 28 | raise ZabbixNotFoundError(f"No proxies matching pattern {pattern!r}") 29 | return random.choice(proxies) 30 | 31 | 32 | def get_proxy_map(client: ZabbixAPI) -> dict[str, Proxy]: 33 | """Fetch all proxies and return a mapping of proxy IDs to Proxy objects.""" 34 | proxies = client.get_proxies() 35 | return {proxy.proxyid: proxy for proxy in proxies} 36 | -------------------------------------------------------------------------------- /zabbix_cli/repl/__init__.py: -------------------------------------------------------------------------------- 1 | """This module is based on click-repl (https://github.com/click-contrib/click-repl) @ f08ba39 2 | 3 | Click-repl is licensed under the MIT license and has the following copyright notices: 4 | Copyright (c) 2014-2015 Markus Unterwaditzer & contributors. 5 | Copyright (c) 2016-2026 Asif Saif Uddin & contributors. 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of 8 | this software and associated documentation files (the "Software"), to deal in 9 | the Software without restriction, including without limitation the rights to 10 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 11 | of the Software, and to permit persons to whom the Software is furnished to do 12 | so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | 25 | The module uses the original click-repl code as a basis for providing a REPL 26 | with autocompletion for the application. Several modifications have been made 27 | to adapt the code to the needs of Zabbix-CLI, as well as removing compatibility 28 | with Python 2 and Click < 8.0. 29 | 30 | Most prominently, the code has been refactored to take in a Typer app when 31 | starting the REPL, which allows us to pass in more information about the available 32 | commands and options to the REPL. 33 | 34 | Furthermore, type annotations have been added to the entire vendored codebase, 35 | and changes have been made in order to make the original code pass type checking. 36 | Among these changes is removing code that adds compatibility with Click < 8.0, which 37 | relied a lot on duck typing and was generally not type-safe. 38 | """ 39 | -------------------------------------------------------------------------------- /zabbix_cli/scripts/__init__.py: -------------------------------------------------------------------------------- 1 | """DEPRECATED: Zabbix CLI scripts. 2 | 3 | These scripts are now baked directly into the CLI. The current scripts 4 | are just shims to call the CLI with the appropriate arguments. 5 | """ 6 | 7 | from __future__ import annotations 8 | -------------------------------------------------------------------------------- /zabbix_cli/scripts/bulk_execution.py: -------------------------------------------------------------------------------- 1 | """DEPRECATED: Bulk execution of Zabbix commands.""" 2 | 3 | from __future__ import annotations 4 | 5 | import subprocess 6 | from pathlib import Path 7 | from typing import Optional 8 | 9 | import typer 10 | 11 | from zabbix_cli.output.console import exit_err 12 | from zabbix_cli.output.console import warning 13 | 14 | app = typer.Typer( 15 | name="zabbix-cli-bulk-execution", help="Bulk execution of Zabbix commands" 16 | ) 17 | 18 | 19 | @app.callback(invoke_without_command=True) 20 | def main_callback( 21 | ctx: typer.Context, 22 | input_file: Optional[Path] = typer.Argument( 23 | None, 24 | help="File to read commands from.", 25 | ), 26 | config_file: Optional[Path] = typer.Option( 27 | None, 28 | "--config", 29 | "-c", 30 | help="Alternate configuration file to use.", 31 | ), 32 | input_file_legacy: Optional[Path] = typer.Option( 33 | None, 34 | "--file", 35 | "--input-file", 36 | "-f", 37 | hidden=True, 38 | help="File to read commands from.", 39 | ), 40 | ) -> None: 41 | warning( 42 | "zabbix-cli-bulk-execution is deprecated. Use [command]zabbix-cli --file[/] instead." 43 | ) 44 | 45 | f = input_file or input_file_legacy 46 | if not f: 47 | exit_err("No input file provided. Reading from stdin is not supported.") 48 | 49 | # HACK: run the CLI with the file argument 50 | args = ["zabbix-cli", "--file", str(f)] 51 | if config_file: 52 | args.extend(["--config", str(config_file)]) 53 | subprocess.run(args) 54 | 55 | 56 | def main() -> None: 57 | """Main entry point for the CLI.""" 58 | app() 59 | 60 | 61 | if __name__ == "__main__": 62 | main() 63 | -------------------------------------------------------------------------------- /zabbix_cli/scripts/init.py: -------------------------------------------------------------------------------- 1 | """DEPRECATED: Set up Zabbix-CLI configuration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import subprocess 6 | from pathlib import Path 7 | from typing import Optional 8 | 9 | import typer 10 | 11 | from zabbix_cli.output.console import warning 12 | 13 | app = typer.Typer(name="zabbix-cli-init", help="Set up Zabbix-CLI configuration") 14 | 15 | 16 | @app.callback(invoke_without_command=True) 17 | def main_callback( 18 | zabbix_url: Optional[str] = typer.Option( 19 | None, 20 | "--url", 21 | "--zabbix-url", 22 | "-z", 23 | help="Zabbix API URL to use", 24 | ), 25 | zabbix_user: Optional[str] = typer.Option( 26 | None, 27 | "--user", 28 | "--zabbix-user", 29 | help="Zabbix API username to use", 30 | ), 31 | config_file: Optional[Path] = typer.Option( 32 | None, 33 | "--config-file", 34 | "-c", 35 | help="Use non-default configuration file location", 36 | ), 37 | overwrite: bool = typer.Option( 38 | False, 39 | "--overwrite", 40 | "-o", 41 | help="Overwrite existing configuration file", 42 | ), 43 | ) -> None: 44 | warning( 45 | "[command]zabbix-cli-init[/] is deprecated. Use [command]zabbix-cli init[/]." 46 | ) 47 | 48 | # HACK: run the CLI with the init command 49 | args = ["zabbix-cli", "init"] 50 | if zabbix_url: 51 | args.extend(["--url", zabbix_url]) 52 | if zabbix_user: 53 | args.extend(["--user", zabbix_user]) 54 | if config_file: 55 | args.extend(["--config-file", str(config_file)]) 56 | if overwrite: 57 | args.append("--overwrite") 58 | 59 | subprocess.run(args) 60 | 61 | 62 | def main() -> None: 63 | """Run the CLI.""" 64 | app() 65 | 66 | 67 | if __name__ == "__main__": 68 | main() 69 | -------------------------------------------------------------------------------- /zabbix_cli/table.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from rich import box 6 | from rich.table import Table 7 | 8 | if TYPE_CHECKING: 9 | from zabbix_cli.models import ColsType 10 | from zabbix_cli.models import RowsType 11 | 12 | 13 | def get_table( 14 | cols: ColsType, 15 | rows: RowsType, 16 | title: str | None = None, 17 | *, 18 | show_lines: bool = True, 19 | box: box.Box = box.ROUNDED, 20 | ) -> Table: 21 | """Returns a Rich table given a list of columns and rows.""" 22 | table = Table(title=title, box=box, show_lines=show_lines) 23 | for col in cols: 24 | table.add_column(col, overflow="fold") 25 | for row in rows: 26 | # We might have subtables in the rows. 27 | # If they have no rows, we don't want to render them. 28 | row = [cell if not isinstance(cell, Table) or cell.rows else "" for cell in row] 29 | table.add_row(*row) 30 | return table 31 | -------------------------------------------------------------------------------- /zabbix_cli/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """Utility functions.""" 2 | 3 | from __future__ import annotations 4 | 5 | from .utils import * # noqa 6 | -------------------------------------------------------------------------------- /zabbix_cli/utils/commands.py: -------------------------------------------------------------------------------- 1 | """Utility functions for working with CLI commands.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | import typer 8 | import typer.core 9 | 10 | from zabbix_cli.exceptions import ZabbixCLIError 11 | 12 | if TYPE_CHECKING: 13 | import click 14 | 15 | 16 | def get_parent_ctx( 17 | ctx: typer.Context | click.core.Context, 18 | ) -> typer.Context | click.core.Context: 19 | """Get the top-level parent context of a context.""" 20 | if ctx.parent is None: 21 | return ctx 22 | return get_parent_ctx(ctx.parent) 23 | 24 | 25 | def get_command_help(command: typer.models.CommandInfo) -> str: 26 | """Get the help text of a command.""" 27 | if command.help: 28 | return command.help 29 | if command.callback and command.callback.__doc__: 30 | lines = command.callback.__doc__.strip().splitlines() 31 | if lines: 32 | return lines[0] 33 | if command.short_help: 34 | return command.short_help 35 | return "" 36 | 37 | 38 | def get_command_by_name(ctx: typer.Context, name: str) -> click.core.Command: 39 | """Get a CLI command given its name.""" 40 | if not isinstance(ctx.command, typer.core.TyperGroup): 41 | # NOTE: ideally we shouldn't leak this error to the user, but 42 | # can this even happen? Isn't it always a command group? 43 | raise ZabbixCLIError(f"Command {ctx.command.name} is not a command group.") 44 | if not ctx.command.commands: 45 | raise ZabbixCLIError(f"Command group {ctx.command.name} has no commands.") 46 | command = ctx.command.commands.get(name) 47 | if not command: 48 | raise ZabbixCLIError(f"Command {name} not found.") 49 | return command 50 | -------------------------------------------------------------------------------- /zabbix_cli/utils/fs.py: -------------------------------------------------------------------------------- 1 | """Filesystem utilities.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import os 7 | import re 8 | import subprocess 9 | import sys 10 | from collections.abc import Generator 11 | from contextlib import contextmanager 12 | from pathlib import Path 13 | from typing import Optional 14 | 15 | from zabbix_cli.exceptions import ZabbixCLIError 16 | from zabbix_cli.exceptions import ZabbixCLIFileError 17 | from zabbix_cli.exceptions import ZabbixCLIFileNotFoundError 18 | from zabbix_cli.output.console import print_path 19 | from zabbix_cli.output.console import success 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | def read_file(file: Path) -> str: 25 | """Attempts to read the contents of a file.""" 26 | if not file.exists(): 27 | raise ZabbixCLIFileNotFoundError(f"File {file} does not exist") 28 | if not file.is_file(): 29 | raise ZabbixCLIFileError(f"{file} is not a file") 30 | try: 31 | return file.read_text() 32 | except OSError as e: 33 | raise ZabbixCLIFileError(f"Unable to read file {file}") from e 34 | 35 | 36 | def open_directory( 37 | directory: Path, command: Optional[str] = None, *, force: bool = False 38 | ) -> None: 39 | """Open directory in file explorer. 40 | 41 | Prints the path to the directory to stderr if no window server is detected. 42 | The path must be a directory, otherwise a ZabbixCLIError is raised. 43 | 44 | Args: 45 | directory (Path): The directory to open. 46 | command (str, optional): The command to use to open the directory. If `None`, the command is determined based on the platform. 47 | force (bool, optional): If `True`, open the directory even if no window server is detected. Defaults to `False`. 48 | """ 49 | try: 50 | if not directory.exists(): 51 | raise FileNotFoundError 52 | directory = directory.resolve(strict=True) 53 | except FileNotFoundError as e: 54 | raise ZabbixCLIError(f"Directory {directory} does not exist") from e 55 | except OSError as e: 56 | raise ZabbixCLIError(f"Unable to resolve symlinks for {directory}") from e 57 | if not directory.is_dir(): 58 | raise ZabbixCLIError(f"{directory} is not a directory") 59 | 60 | spath = str(directory) 61 | if sys.platform == "win32": 62 | subprocess.run([command or "explorer", spath]) 63 | elif sys.platform == "darwin": 64 | subprocess.run([command or "open", spath]) 65 | else: # Linux and Unix 66 | if not os.environ.get("DISPLAY") and not force: 67 | print_path(directory) 68 | return 69 | subprocess.run([command or "xdg-open", spath]) 70 | success(f"Opened {directory}") 71 | 72 | 73 | def mkdir_if_not_exists(path: Path) -> None: 74 | """Create a directory for a given path if it does not exist. 75 | 76 | Returns the path if it was created, otherwise None. 77 | """ 78 | if path.exists(): 79 | return 80 | try: 81 | path.mkdir(parents=True, exist_ok=True) 82 | except Exception as e: 83 | raise ZabbixCLIFileError(f"Failed to create directory {path}: {e}") from e 84 | else: 85 | logger.info("Created directory: %s", path) 86 | 87 | 88 | def sanitize_filename(filename: str) -> str: 89 | """Make a filename safe(r) for use in filesystems. 90 | 91 | Very naive implementation that removes illegal characters. 92 | Does not check for reserved names or path length. 93 | """ 94 | return re.sub(r"[^\w\-.]", "_", filename) 95 | 96 | 97 | def make_executable(path: Path) -> None: 98 | """Make a file executable.""" 99 | if sys.platform == "win32": 100 | logger.debug("Skipping making file %s executable on Windows", path) 101 | return 102 | 103 | if not path.exists(): 104 | raise ZabbixCLIFileNotFoundError( 105 | f"File {path} does not exist. Unable to make it executable." 106 | ) 107 | mode = path.stat().st_mode 108 | new_mode = mode | (mode & 0o444) >> 2 # copy R bits to X 109 | if new_mode != mode: 110 | path.chmod(new_mode) 111 | logger.info("Changed file mode of %s from %o to %o", path, mode, new_mode) 112 | else: 113 | logger.debug("File %s is already executable", path) 114 | 115 | 116 | def move_file(src: Path, dest: Path, *, mkdir: bool = True) -> None: 117 | """Move a file to a new location.""" 118 | try: 119 | if mkdir: 120 | mkdir_if_not_exists(dest.parent) 121 | src.rename(dest) 122 | except Exception as e: 123 | raise ZabbixCLIError(f"Failed to move {src} to {dest}: {e}") from e 124 | else: 125 | logger.info("Moved %s to %s", src, dest) 126 | 127 | 128 | @contextmanager 129 | def temp_directory() -> Generator[Path, None, None]: 130 | """Context manager for creating a temporary directory. 131 | 132 | Ripped from: https://github.com/pypa/hatch/blob/35f8ffdacc937bdcf3b250e0be1bbdf5cde30c4c/src/hatch/utils/fs.py#L112-L117 133 | """ 134 | from tempfile import TemporaryDirectory 135 | 136 | with TemporaryDirectory() as d: 137 | yield Path(d).resolve() 138 | -------------------------------------------------------------------------------- /zabbix_cli/utils/rich.py: -------------------------------------------------------------------------------- 1 | """Utility functions for working with the Rich library.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING 7 | 8 | from rich.errors import MarkupError 9 | from rich.text import Text 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | if TYPE_CHECKING: 14 | from rich.console import RenderableType 15 | 16 | 17 | def get_safe_renderable(renderable: RenderableType) -> RenderableType: 18 | """Ensure that the renderable can be rendered without raising an exception.""" 19 | if isinstance(renderable, str): 20 | return get_text(renderable) 21 | return renderable 22 | 23 | 24 | def get_text(text: str, *, log: bool = True) -> Text: 25 | """Interpret text as markup-styled text, or plain text if it fails.""" 26 | try: 27 | return Text.from_markup(text) 28 | except MarkupError as e: 29 | # Log this so that we can more easily debug incorrect rendering 30 | # In most cases, this will be due to some Zabbix item key that looks 31 | # like a markup tag, e.g. `system.cpu.load[percpu,avg]` 32 | # but we need to log it nonetheless for other cases 33 | # However, we don't want to log when we're removing markup 34 | # from log records, so we have a `log` parameter to control this. 35 | if log: 36 | logger.debug("Markup error when rendering text: '%s': %s", text, e) 37 | return Text(text) 38 | --------------------------------------------------------------------------------