diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..2fe6142 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..c991bff --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,72 @@ +name: Tests + +on: + pull_request: ~ + push: + branches: [ main ] + + # Allow job to be triggered manually. + workflow_dispatch: + +# Cancel in-progress jobs when pushing to the same branch. +concurrency: + cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.ref }} + +jobs: + + tests: + + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: ["ubuntu-latest"] + python-version: ["3.9", "3.12"] + fail-fast: false + + env: + OS: ${{ matrix.os }} + PYTHON: ${{ matrix.python-version }} + + name: " + Python ${{ matrix.python-version }}, + OS ${{ matrix.os }}" + steps: + + - name: Acquire sources + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + cache: 'pip' + cache-dependency-path: | + pyproject.toml + setup.py + + - name: Set up project + run: | + + # `setuptools 0.64.0` adds support for editable install hooks (PEP 660). + # https://github.com/pypa/setuptools/blob/main/CHANGES.rst#v6400 + pip install "setuptools>=64" --upgrade + + # Install package in editable mode. + pip install --use-pep517 --prefer-binary --editable=.[test,develop] + + - name: Run linter and software tests + run: | + poe check + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + files: ./coverage.xml + flags: unittests + env_vars: OS,PYTHON + name: codecov-umbrella + fail_ci_if_error: false diff --git a/.gitignore b/.gitignore index c6525cd..784de3a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ build/* dist/* *.egg-*/ .venv* +.coverage* +coverage.xml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 44fe4fa..d40941f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,7 @@ # Contributing -Contributions are welcome, and they are greatly appreciated! Every -little bit helps, and credit will always be given. +Contributions are welcome, and they are greatly appreciated. Every +little helps, and credit will always be given. You can contribute in many ways: @@ -88,6 +88,5 @@ Before you submit a pull request, check that it meets these guidelines: ## Tips -To run a subset of tests: - - $ pytest tests +For installing a development sandbox, please refer to the documentation +about the [development sandbox](./docs/sandbox.md). diff --git a/MANIFEST.in b/MANIFEST.in index 7d43f80..c1bf2ce 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ include *.md include LICENSE +recursive-include grafana_import *.yml *.yaml diff --git a/README.md b/README.md index 1cf12d9..3090e52 100644 --- a/README.md +++ b/README.md @@ -155,5 +155,16 @@ grafana-import -f Applications -d "my-first-dashboard" remove ``` +## Contributing + +Contributions are welcome, and they are greatly appreciated. You can contribute +in many ways, and credit will always be given. + +For learning more how to contribute, see the [contribution guidelines] and +learn how to set up a [development sandbox]. + + +[contribution guidelines]: ./CONTRIBUTING.md +[development sandbox]: ./docs/sandbox.md [Grafana HTTP API]: https://grafana.com/docs/grafana/latest/http_api/ [grafana-client]: https://github.com/panodata/grafana-client diff --git a/docs/sandbox.md b/docs/sandbox.md new file mode 100644 index 0000000..e7d5682 --- /dev/null +++ b/docs/sandbox.md @@ -0,0 +1,48 @@ +# Development Sandbox + + +## Setup +Those commands will get you started with a sandboxed development environment. +After invoking `poe check`, and observing the software tests succeed, you +should be ready to start hacking. + +```shell +git clone https://github.com/peekjef72/grafana-import-tool +cd grafana-import-tool +python3 -m venv .venv +source .venv/bin/activate +pip install --editable='.[develop,test]' +``` + + +## Software tests + +For running the software tests after setup, invoke `poe check`. +Optionally, activate the virtualenv, if you are coming back to +development using a fresh terminal session. + +Run linters and software tests. +```shell +source .venv/bin/activate +poe check +``` + +Run a subset of tests. +```shell +pytest -k core +``` + + +## Releasing + +```shell +# Install a few more prerequisites. +pip install --editable='.[release]' + +# Designate a new version. +git tag v0.1.0 +git push --tags + +# Build package, and publish to PyPI. +poe release +``` diff --git a/grafana_import/cli.py b/grafana_import/cli.py index 530aa6a..c2e4d23 100644 --- a/grafana_import/cli.py +++ b/grafana_import/cli.py @@ -25,7 +25,7 @@ import grafana_client.client as GrafanaApi import grafana_import.grafana as Grafana -import yaml +from grafana_import.util import load_yaml_config, grafana_settings #****************************************************************************************** config = None @@ -34,18 +34,19 @@ def save_dashboard(config, args, base_path, dashboard_name, dashboard, action): output_file = base_path + file_name = dashboard_name if 'exports_path' in config['general'] and \ not re.search(r'^(\.|\/)?/', config['general']['exports_path']): output_file = os.path.join(output_file, config['general']['exports_path'] ) if 'export_suffix' in config['general']: - dashboard_name += datetime.today().strftime(config['general']['export_suffix']) + file_name += datetime.today().strftime(config['general']['export_suffix']) if 'meta' in dashboard and 'folderId' in dashboard['meta'] and dashboard['meta']['folderId'] != 0: - dashboard_name = dashboard['meta']['folderTitle'] + '_' + dashboard_name + file_name = dashboard['meta']['folderTitle'] + '_' + file_name - file_name = Grafana.remove_accents_and_space( dashboard_name ) + file_name = Grafana.remove_accents_and_space( file_name ) output_file = os.path.join(output_file, file_name + '.json') try: output = open(output_file, 'w') @@ -60,7 +61,7 @@ def save_dashboard(config, args, base_path, dashboard_name, dashboard, action): content = json.dumps( dashboard['dashboard'] ) output.write( content ) output.close() - print("OK: dashboard {1} to '{0}'.".format(output_file, action)) + print(f"OK: Dashboard '{dashboard_name}' {action} to: {output_file}") #****************************************************************************************** class myArgs: @@ -154,19 +155,7 @@ def main(): else: config_file = args.config_file - config = None - try: - with open(config_file, 'r') as cfg_fh: - try: - config = yaml.safe_load(cfg_fh) - except yaml.scanner.ScannerError as exc: - mark = exc.problem_mark - print("Yaml file parsing unsuccessul : %s - line: %s column: %s => %s" % (config_file, mark.line+1, mark.column+1, exc.problem) ) - except Exception as exp: - print('ERROR: config file not read: %s' % str(exp)) - - if config is None: - sys.exit(1) + config = load_yaml_config(config_file) if args.verbose is None: if 'debug' in config['general']: @@ -203,34 +192,16 @@ def main(): if 'export_suffix' not in config['general'] or config['general']['export_suffix'] is None: config['general']['export_suffix'] = "_%Y%m%d%H%M%S" - if not args.grafana_label in config['grafana']: - print("ERROR: invalid grafana config label has been specified (-g {0}).".format(args.grafana_label)) - sys.exit(1) - - #** init default conf from grafana with set label. - config['grafana'] = config['grafana'][args.grafana_label] - -#************ - if not 'token' in config['grafana']: - print("ERROR: no token has been specified in grafana config label '{0}'.".format(args.grafana_label)) - sys.exit(1) - - params = { - 'host': config['grafana'].get('host', 'localhost'), - 'protocol': config['grafana'].get('protocol', 'http'), - 'port': config['grafana'].get('port', '3000'), - 'token': config['grafana'].get('token'), - 'verify_ssl': config['grafana'].get('verify_ssl', True), - 'search_api_limit': config['grafana'].get('search_api_limit', 5000), - 'folder': config['general'].get('grafana_folder', 'General'), - 'overwrite': args.overwrite, - 'allow_new': args.allow_new, - } + params = grafana_settings(config=config, label=args.grafana_label) + params.update({ + 'overwrite': args.overwrite, + 'allow_new': args.allow_new, + }) try: grafana_api = Grafana.Grafana( **params ) except Exception as e: - print("ERROR: {} - message: {}".format(e) ) + print(f"ERROR: {e}") sys.exit(1) #******************************************************************************* @@ -268,44 +239,49 @@ def main(): print("maybe you want to set --overwrite option.") sys.exit(1) + title = dash['title'] + folder_name = grafana_api.grafana_folder if res: - print("OK: dashboard '{0}' imported into '{1}'.".format(dash['title'], grafana_api.grafana_folder)) + print(f"OK: Dashboard '{title}' imported into folder '{folder_name}'") sys.exit(0) else: - print("KO: dashboard '{0}' not imported into '{1}'.".format(dash['title'], grafana_api.grafana_folder)) + print(f"KO: Dashboard '{title}' not imported into folder '{folder_name}'") sys.exit(1) #******************************************************************************* elif args.action == 'remove': + dashboard_name = config['general']['dashboard_name'] try: - res = grafana_api.remove_dashboard(config['general']['dashboard_name']) - print("OK: dashboard '{0}' removed.".format(config['general']['dashboard_name'])) + res = grafana_api.remove_dashboard(dashboard_name) + print(f"OK: Dashboard removed: {dashboard_name}") + sys.exit(0) except Grafana.GrafanaDashboardNotFoundError as exp: - print("KO: dashboard '{0}' not found in '{1}".format(exp.dashboard, exp.folder)) + print(f"KO: Dashboard not found in folder '{exp.folder}': {exp.dashboard}") sys.exit(0) except Grafana.GrafanaFolderNotFoundError as exp: - print("KO: folder '{0}' not found".format(exp.folder)) + print(f"KO: Folder not found: {exp.folder}") sys.exit(0) except GrafanaApi.GrafanaBadInputError as exp: - print("KO: dashboard '{0}' not removed: {1}".format(config['general']['dashboard_name'], exp)) + print(f"KO: Removing dashboard failed: {dashboard_name}. Reason: {exp}") sys.exit(1) except Exception as exp: - print("error: dashboard '{0}' remove exception '{1}'".format(config['general']['dashboard_name'], traceback.format_exc())) + print("ERROR: Dashboard '{0}' remove exception '{1}'".format(dashboard_name, traceback.format_exc())) sys.exit(1) #******************************************************************************* else: # export or + dashboard_name = config['general']['dashboard_name'] try: - dash = grafana_api.export_dashboard(config['general']['dashboard_name']) + dash = grafana_api.export_dashboard(dashboard_name) except (Grafana.GrafanaFolderNotFoundError, Grafana.GrafanaDashboardNotFoundError): - print("KO: dashboard name not found '{0}'".format(config['general']['dashboard_name'])) + print("KO: Dashboard name not found: {0}".format(dashboard_name)) sys.exit(1) except Exception as exp: - print("error: dashboard '{0}' export exception '{1}'".format(config['general']['dashboard_name'], traceback.format_exc())) + print("ERROR: Dashboard '{0}' export exception '{1}'".format(dashboard_name, traceback.format_exc())) sys.exit(1) if dash is not None: - save_dashboard(config, args, base_path, config['general']['dashboard_name'], dash, 'exported') + save_dashboard(config, args, base_path, dashboard_name, dash, 'exported') sys.exit(0) # end main... diff --git a/grafana_import/grafana.py b/grafana_import/grafana.py index 154c5ae..a8af5a8 100644 --- a/grafana_import/grafana.py +++ b/grafana_import/grafana.py @@ -3,6 +3,7 @@ #****************************************************************************************** +import typing as t import grafana_client.api as GrafanaApi import grafana_client.client as GrafanaClient import re, traceback, unicodedata @@ -54,15 +55,15 @@ def remove_accents_and_space(input_str): """ nfkd_form = unicodedata.normalize('NFKD', input_str) res = u"".join([c for c in nfkd_form if not unicodedata.combining(c)]) - res = re.sub('\s+', '_', res) + res = re.sub(r'\s+', '_', res) return res #****************************************************************************************** class Grafana(object): #* to store the folders list, dashboards list (kind of cache) - folders = [] - dashboards =[] + folders: t.List[t.Any] = [] + dashboards: t.List[t.Any] = [] #*********************************************** def __init__( *args, **kwargs ): @@ -159,25 +160,28 @@ def export_dashboard(self, dashboard_name): dashboard (dict [json]) """ - board = self.find_dashboard(dashboard_name) - - if board is not None: + try: + board = self.find_dashboard(dashboard_name) - #* collect the board object itself from it uid. - try: - board = self.grafana_api.dashboard.get_dashboard(board['uid']) - except Exception as e: + if board is None: + raise GrafanaClient.GrafanaClientError(response=None, message="Not Found", status_code=404) + + # Fetch the dashboard JSON representation by UID. + board = self.grafana_api.dashboard.get_dashboard(board['uid']) + return board + except Exception as ex: + if isinstance(ex, GrafanaClient.GrafanaClientError) and ex.status_code == 404: + raise GrafanaDashboardNotFoundError( + dashboard_name, + self.grafana_folder, + f"Dashboard not found: {dashboard_name}") + else: raise - else: - raise GrafanaDashboardNotFoundError(dashboard_name, self.grafana_folder, 'dashboard not found') - - return board - #*********************************************** def remove_dashboard(self, dashboard_name): """ - retrive the dashboard object from Grafana server and remove it. + Retrieve the dashboard object from Grafana server and remove it. params: dashboard_name (str): name of the dashboard to retrieve result: @@ -195,7 +199,10 @@ def remove_dashboard(self, dashboard_name): #** check 'custom' folder existence (custom != General) folder = self.get_folder( self.grafana_folder ) if folder is None: - raise GrafanaFolderNotFoundError(self.grafana_folder, 'folder not found') + raise GrafanaFolderNotFoundError( + self.grafana_folder, + f"Folder not found: {self.grafana_folder}", + ) #* collect the board object itself from it uid. try: diff --git a/grafana_import/util.py b/grafana_import/util.py new file mode 100644 index 0000000..5204cb0 --- /dev/null +++ b/grafana_import/util.py @@ -0,0 +1,58 @@ +import typing as t + +import yaml + +ConfigType = t.Dict[str, t.Any] + + +def load_yaml_config(config_file: str) -> ConfigType: + """ + Load configuration file in YAML format from disk. + """ + try: + with open(config_file, "r") as cfg_fh: + try: + return yaml.safe_load(cfg_fh) + except yaml.scanner.ScannerError as ex: + mark = ex.problem_mark + msg = "YAML file parsing failed : %s - line: %s column: %s => %s" % ( + config_file, + mark and mark.line + 1, + mark and mark.column + 1, + ex.problem, + ) + raise ValueError(f"Configuration file invalid: {config_file}. Reason: {msg}") from ex + except Exception as ex: + raise ValueError(f"Reading configuration file failed: {ex}") from ex + + +def grafana_settings(config: ConfigType, label: str) -> t.Dict[str, t.Union[str, int, bool]]: + """ + Extract Grafana connection profile from configuration dictionary, by label. + + The configuration contains multiple connection profiles within the `grafana` + section. In order to address a specific profile, this function accepts a + `label` string. + """ + if not label or label not in config["grafana"]: + raise ValueError(f"Invalid Grafana configuration label: {label}") + + # ** init default conf from grafana with set label. + # FIXME: That is certainly a code smell. + # Q: Has it been introduced later in order to support multiple connection profiles? + config["grafana"] = config["grafana"][label] + + # ************ + if "token" not in config["grafana"]: + raise ValueError(f"Authentication token missing in Grafana configuration at: {label}") + + params = { + "host": config["grafana"].get("host", "localhost"), + "protocol": config["grafana"].get("protocol", "http"), + "port": config["grafana"].get("port", "3000"), + "token": config["grafana"].get("token"), + "verify_ssl": config["grafana"].get("verify_ssl", True), + "search_api_limit": config["grafana"].get("search_api_limit", 5000), + "folder": config["general"].get("grafana_folder", "General"), + } + return params diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8bd2211 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,114 @@ +[tool.black] +line-length = 120 + +[tool.coverage.run] +branch = false +omit = [ + "tests/*", +] +source = ["grafana_import"] + +[tool.coverage.report] +fail_under = 0 +show_missing = true + +[tool.mypy] +packages = ["grafana_import"] +install_types = true +ignore_missing_imports = true +implicit_optional = true +non_interactive = true + +[tool.pytest.ini_options] +addopts = "-rA --verbosity=3 --cov --cov-report=term-missing --cov-report=xml" +minversion = "2.0" +log_level = "DEBUG" +log_cli_level = "DEBUG" +log_format = "%(asctime)-15s [%(name)-24s] %(levelname)-8s: %(message)s" +testpaths = [ + "grafana_import", + "tests", +] +xfail_strict = true +markers = [ +] + +[tool.ruff] +line-length = 120 + +lint.select = [ + # Bandit + "S", + # Bugbear + "B", + # Builtins + "A", + # comprehensions + "C4", + # eradicate + "ERA", + # flake8-2020 + "YTT", + # isort + "I", + # pandas-vet + "PD", + # print + "T20", + # Pycodestyle + "E", + "W", + # Pyflakes + "F", + # return + "RET", +] + +lint.extend-ignore = [ + # Unnecessary variable assignment before `return` statement + "RET504", + # Unnecessary `elif` after `return` statement + "RET505", +] + +# Intermediary ignores, until the code base has been improved further. +lint.exclude = [ + "grafana_import/cli.py", + "grafana_import/grafana.py", +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S101"] # Use of `assert` detected + + +# =================== +# Tasks configuration +# =================== + +[tool.poe.tasks] + +check = [ + "lint", + "test", +] + +format = [ + { cmd = "black ." }, + # Configure Ruff not to auto-fix (remove!) unused variables (F841) and `print` statements (T201). + { cmd = "ruff check --fix --ignore=ERA --ignore=F401 --ignore=F841 --ignore=T20 ." }, + { cmd = "pyproject-fmt --keep-full-version pyproject.toml" }, +] + +lint = [ + { cmd = "ruff check ." }, + # { cmd = "black --check ." }, + { cmd = "validate-pyproject pyproject.toml" }, + { cmd = "mypy" }, +] + +release = [ + { cmd = "python -m build" }, + { cmd = "twine upload dist/*" }, +] + +test = { cmd = "pytest" } diff --git a/setup.py b/setup.py index f995b8f..8a05190 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,8 @@ import os +from setuptools import find_packages, setup + from grafana_import.constants import PKG_NAME, PKG_VERSION -from setuptools import setup, find_packages # Global variables requires = [ @@ -10,6 +11,24 @@ 'pyyaml<7', ] +extras = { + "develop": [ + "black<25", + "mypy<1.10", + "poethepoet<0.26", + "pyproject-fmt<1.8", + "ruff<0.5", + "validate-pyproject<0.17", + ], + "test": [ + "grafana-dashboard==0.1.1", + "pydantic<2", + "pytest<9", + "pytest-cov<6", + "responses<0.26", + ], +} + here = os.path.abspath(os.path.dirname(__file__)) README = open(os.path.join(here, "README.md")).read() @@ -30,6 +49,7 @@ }, packages=find_packages(), install_requires=requires, + extras_require=extras, package_data={'': ['conf/*']}, classifiers=[ "Programming Language :: Python", @@ -47,9 +67,6 @@ "Operating System :: Unix", "Operating System :: MacOS", "Programming Language :: Python", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..faca876 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,64 @@ +from importlib.resources import files + +import pytest +import responses + +from grafana_import.grafana import Grafana +from grafana_import.util import grafana_settings, load_yaml_config +from tests.util import mock_grafana_health, mock_grafana_search + + +@pytest.fixture(scope="session", autouse=True) +def niquests_patch_all(): + """ + Patch module namespace, pretend Niquests is Requests. + """ + from sys import modules + + import niquests + import urllib3 + + # Amalgamate the module namespace to make all modules aiming + # to use `requests`, in fact use `niquests` instead. + modules["requests"] = niquests + modules["requests.adapters"] = niquests.adapters + modules["requests.sessions"] = niquests.sessions + modules["requests.exceptions"] = niquests.exceptions + modules["requests.packages.urllib3"] = urllib3 + + +@pytest.fixture +def mocked_responses(): + """ + Provide the `responses` mocking machinery to a pytest environment. + """ + with responses.RequestsMock() as rsps: + yield rsps + + +@pytest.fixture +def mocked_grafana(mocked_responses): + mock_grafana_health(mocked_responses) + mock_grafana_search(mocked_responses) + + +@pytest.fixture +def config(): + config_file = files("grafana_import") / "conf" / "grafana-import.yml" + return load_yaml_config(str(config_file)) + + +@pytest.fixture +def settings(config): + return grafana_settings(config, label="default") + + +@pytest.fixture(autouse=True) +def reset_grafana_importer(): + Grafana.folders = [] + Grafana.dashboards = [] + + +@pytest.fixture +def gio(settings) -> Grafana: + return Grafana(**settings) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..b7396b0 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,105 @@ +import json +import re +import shlex +import sys +from pathlib import Path +from unittest import mock + +import pytest + +from grafana_import.cli import main +from tests.util import mkdashboard, open_write_noop + +CONFIG_FILE = "grafana_import/conf/grafana-import.yml" + + +def test_import_dashboard_success(mocked_grafana, mocked_responses, tmp_path, capsys): + """ + Verify "import dashboard" works. + """ + mocked_responses.post( + "http://localhost:3000/api/dashboards/db", + json={"status": "ok"}, + status=200, + content_type="application/json", + ) + + dashboard = mkdashboard() + dashboard_file = Path(tmp_path / "dashboard.json") + dashboard_file.write_text(json.dumps(dashboard, indent=2)) + + sys.argv = shlex.split(f"grafana-import import --config_file {CONFIG_FILE} --dashboard_file {dashboard_file}") + + with pytest.raises(SystemExit) as ex: + main() + assert ex.match("0") + + out, err = capsys.readouterr() + assert "OK: Dashboard 'Dashboard One' imported into folder 'General'" in out + + +def test_export_dashboard_success(mocked_grafana, mocked_responses, capsys): + """ + Verify "export dashboard" works. + """ + + mocked_responses.get( + "http://localhost:3000/api/dashboards/uid/618f7589-7e3d-4399-a585-372df9fa5e85", + json={"dashboard": {}}, + status=200, + content_type="application/json", + ) + + sys.argv = shlex.split(f"grafana-import export --config_file {CONFIG_FILE} --dashboard_name foobar") + + with pytest.raises(SystemExit) as ex: + m = mock.patch("builtins.open", open_write_noop) + m.start() + main() + m.stop() + assert ex.match("0") + + out, err = capsys.readouterr() + assert re.match(r"OK: Dashboard 'foobar' exported to: ./foobar_\d+.json", out) + + +def test_export_dashboard_notfound(mocked_grafana, mocked_responses, capsys): + """ + Verify "export dashboard" fails appropriately when addressed dashboard does not exist. + """ + + mocked_responses.get( + "http://localhost:3000/api/dashboards/uid/618f7589-7e3d-4399-a585-372df9fa5e85", + json={}, + status=404, + content_type="application/json", + ) + + sys.argv = shlex.split(f"grafana-import export --config_file {CONFIG_FILE} --dashboard_name foobar") + with pytest.raises(SystemExit) as ex: + main() + assert ex.match("1") + + out, err = capsys.readouterr() + assert "Dashboard name not found: foobar" in out + + +def test_remove_dashboard_success(mocked_grafana, mocked_responses, settings, capsys): + """ + Verify "remove dashboard" works. + """ + mocked_responses.delete( + "http://localhost:3000/api/dashboards/uid/618f7589-7e3d-4399-a585-372df9fa5e85", + json={"status": "ok"}, + status=200, + content_type="application/json", + ) + + sys.argv = shlex.split(f"grafana-import remove --config_file {CONFIG_FILE} --dashboard_name foobar") + + with pytest.raises(SystemExit) as ex: + main() + assert ex.match("0") + + out, err = capsys.readouterr() + assert "OK: Dashboard removed: foobar" in out diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..c3aa586 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,92 @@ +import pytest + +from grafana_import.grafana import Grafana, GrafanaDashboardNotFoundError, GrafanaFolderNotFoundError +from tests.util import mkdashboard, mock_grafana_health + + +def test_find_dashboard_success(mocked_grafana, gio): + """ + Verify "find dashboard" works. + """ + + results = gio.find_dashboard("foobar") + assert results == {"title": "foobar", "uid": "618f7589-7e3d-4399-a585-372df9fa5e85"} + + +def test_import_dashboard_success(mocked_grafana, mocked_responses, gio): + """ + Verify "import dashboard" works. + """ + mocked_responses.post( + "http://localhost:3000/api/dashboards/db", + json={"status": "ok"}, + status=200, + content_type="application/json", + ) + + dashboard = mkdashboard() + outcome = gio.import_dashboard(dashboard) + assert outcome is True + + +def test_export_dashboard_success(mocked_grafana, mocked_responses, gio): + """ + Verify "export dashboard" works. + """ + mocked_responses.get( + "http://localhost:3000/api/dashboards/uid/618f7589-7e3d-4399-a585-372df9fa5e85", + json={"dashboard": {}}, + status=200, + content_type="application/json", + ) + + dashboard = gio.export_dashboard("foobar") + assert dashboard == {"dashboard": {}} + + +def test_export_dashboard_notfound(mocked_grafana, mocked_responses, gio): + """ + Verify "export dashboard" using an unknown dashboard croaks as expected. + """ + with pytest.raises(GrafanaDashboardNotFoundError) as ex: + gio.export_dashboard("unknown") + assert ex.match("Dashboard not found: unknown") + + +def test_remove_dashboard_success(mocked_grafana, mocked_responses, settings): + """ + Verify "remove dashboard" works. + """ + mocked_responses.delete( + "http://localhost:3000/api/dashboards/uid/618f7589-7e3d-4399-a585-372df9fa5e85", + json={"status": "ok"}, + status=200, + content_type="application/json", + ) + + gio = Grafana(**settings) + outcome = gio.remove_dashboard("foobar") + assert outcome is True + + +def test_remove_dashboard_folder_not_found(mocked_responses, settings): + """ + Verify "remove dashboard" works. + """ + + mock_grafana_health(mocked_responses) + + mocked_responses.get( + "http://localhost:3000/api/folders", + json=[], + status=200, + content_type="application/json", + ) + + settings["folder"] = "non-standard" + gio = Grafana(**settings) + + with pytest.raises(GrafanaFolderNotFoundError) as ex: + gio.remove_dashboard("foobar") + + assert ex.match("Folder not found: non-standard") diff --git a/tests/util.py b/tests/util.py new file mode 100644 index 0000000..5b2d95f --- /dev/null +++ b/tests/util.py @@ -0,0 +1,75 @@ +import builtins +import io +import json +import typing as t + +from grafana_dashboard.manual_models import TimeSeries +from grafana_dashboard.model.dashboard_types_gen import Dashboard, GridPos +from grafana_dashboard.model.prometheusdataquery_types_gen import PrometheusDataQuery +from responses import RequestsMock + +if t.TYPE_CHECKING: + from mypy.typeshed.stdlib._typeshed import FileDescriptorOrPath, OpenTextMode + + +def mock_grafana_health(responses: RequestsMock): + """ + Baseline mock for each Grafana conversation. + """ + responses.get( + "http://localhost:3000/api/health", + json={"database": "ok"}, + status=200, + content_type="application/json", + ) + + +def mock_grafana_search(responses: RequestsMock): + responses.get( + "http://localhost:3000/api/search?type=dash-db&limit=5000", + json=[{"title": "foobar", "uid": "618f7589-7e3d-4399-a585-372df9fa5e85"}], + status=200, + content_type="application/json", + ) + + +def mkdashboard(): + """ + Example Grafana dashboard, generated using the `grafana-dashboard` package. + + https://github.com/fzyzcjy/grafana_dashboard_python/blob/master/examples/python_to_json/input_python/dashboard-one.py + """ + dashboard = Dashboard( + title='Dashboard One', + panels=[ + TimeSeries( + title='Panel Title', + gridPos=GridPos(x=0, y=0, w=12, h=9), + targets=[ + PrometheusDataQuery( + datasource='Prometheus', + expr='avg(1 - rate(node_cpu_seconds_total{mode="idle"}[$__rate_interval])) by (instance, job)', + legendFormat='{{instance}}' + ) + ], + ) + ], + ).auto_panel_ids() + return json.loads(dashboard.to_grafana_json()) + + +# Bookkeeping for `open_write_noop`. +real_open = builtins.open + + +def open_write_noop(file: "FileDescriptorOrPath", mode: "OpenTextMode" = "r", **kwargs): + """ + A replacement for `builtins.open`, masking all write operations. + """ + if mode and mode.startswith("w"): + if "b" in mode: + return io.BytesIO() + else: + return io.StringIO() + else: + return real_open(file=file, mode=mode, **kwargs)