├── .github └── workflows │ ├── codeql-analysis.yml │ └── pypi-release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── grafana_dashboard_manager ├── __init__.py ├── __main__.py ├── api │ ├── auth.py │ └── rest_client.py ├── commands │ ├── __init__.py │ ├── dashboard_download.py │ └── dashboard_upload.py ├── exceptions.py ├── global_config.py ├── grafana │ ├── __init__.py │ └── grafana_api.py ├── handlers │ ├── api_dashboards.py │ ├── api_folders.py │ └── base_handler.py ├── models │ ├── __init__.py │ ├── dashboard.py │ └── folder.py └── utils.py ├── poetry.lock ├── pyproject.toml ├── ruff.toml └── tests └── integration └── docker-compose.yaml /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '42 1 * * 5' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v2 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v1 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v1 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v1 71 | -------------------------------------------------------------------------------- /.github/workflows/pypi-release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package 2 | run-name: Release to PyPI 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | jobs: 8 | deploy: 9 | environment: PyPI 10 | runs-on: ubuntu-latest 11 | name: Publish to PyPI with Poetry 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/setup-python@v2 15 | with: 16 | python-version: 3.11 17 | - uses: actions/cache@v2 18 | name: Cache setup 19 | with: 20 | path: ~/.local 21 | key: poetry-1.7.1 22 | - uses: snok/install-poetry@v1 23 | with: 24 | version: 1.7.1 25 | virtualenvs-create: true 26 | virtualenvs-in-project: true 27 | - name: Install 28 | run: poetry install --no-interaction --no-root 29 | - name: Build & Publish 30 | run: | 31 | poetry version $(poetry version --short).${GITHUB_RUN_ID}${GITHUB_RUN_ATTEMPT} 32 | poetry publish --build 33 | env: 34 | POETRY_PYPI_TOKEN_PYPI: ${{ secrets.POETRY_PYPI_TOKEN_PYPI }} 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | **/.DS_Store 140 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | # Ruff version. 4 | rev: v0.1.9 5 | hooks: 6 | - name: Run python linter 7 | id: ruff 8 | args: [ --fix ] 9 | types: [ python ] 10 | - name: Run python formatter 11 | id: ruff-format 12 | args: [ ] 13 | types: [ python ] 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This should be considered alpha software to be used at your own risk - it has the capability of overwriting dashboards, so ensure any existing dashboards are backed up first. 4 | 5 | This tool was written to serve our own purposes, and so there are obvious gaps in the implementation. Any contributions welcome, including testing on various platforms, Grafana versions, and python versions. 6 | 7 | Tested with: 8 | - Python 3.11 9 | - Grafana v10.2.3 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim as python-build 2 | 3 | ENV POETRY_VERSION=1.7.1 \ 4 | POETRY_HOME="/opt/poetry" \ 5 | POETRY_NO_INTERACTION=1 6 | 7 | # prepend poetry and venv to path 8 | ENV PATH="$POETRY_HOME/bin:$VENV_PATH/bin:$PATH" 9 | 10 | RUN apt-get update \ 11 | && apt-get install --no-install-recommends -y \ 12 | curl \ 13 | build-essential 14 | 15 | # install poetry 16 | RUN curl -sSL https://install.python-poetry.org | python3 - 17 | RUN poetry self add poetry-plugin-export 18 | RUN poetry config warnings.export false 19 | 20 | # copy project requirement files here to ensure they will be cached. 21 | WORKDIR /build 22 | COPY poetry.lock pyproject.toml ./ 23 | RUN poetry export -f requirements.txt \ 24 | --without-hashes \ 25 | --without dev \ 26 | > requirements.txt 27 | 28 | 29 | ###################################################### 30 | 31 | FROM python:3.11-slim as app 32 | ENV APP_PATH="/app/grafana-dashboard-manager/" 33 | WORKDIR $APP_PATH 34 | 35 | COPY --from=python-build /build/requirements.txt $APP_PATH 36 | COPY --from=python-build /build/pyproject.toml $APP_PATH 37 | COPY README.md $APP_PATH 38 | COPY grafana_dashboard_manager "$APP_PATH/grafana_dashboard_manager" 39 | 40 | RUN pip install -r requirements.txt 41 | RUN pip install $APP_PATH 42 | 43 | ENTRYPOINT ["python", "grafana_dashboard_manager"] 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 BEAM CONNECTIVITY LIMITED 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # grafana-dashboard-manager 2 | 3 | ![CodeQL](https://github.com/Beam-Connectivity/grafana-dashboard-manager/actions/workflows/codeql-analysis.yml/badge.svg) 4 | [![PyPI version](https://badge.fury.io/py/grafana_dashboard_manager.svg)](https://badge.fury.io/py/grafana_dashboard_manager) 5 | 6 | A simple CLI utility for importing and exporting dashboards as JSON using the Grafana HTTP API. 7 | 8 | This can be used for: 9 | 10 | - Backing up your dashboards that already exist within your Grafana instance, e.g. if you are migrating from the internal SQLite database to MySQL. 11 | - Updating dashboard files for your Infrastructure-as-Code for use with [Grafana dashboard provisioning](https://grafana.com/docs/grafana/latest/administration/provisioning/#dashboards). 12 | - Making tweaks to dashboard JSON files directly and updating Grafana with one command. 13 | 14 | ## Features 15 | 16 | - Mirrors the folder structure between a local set of dashboards and Grafana, creating folders where necessary. 17 | - Ensures links to dashboards folders in a `dashlist` Panel are consistent with the Folder UIDs - useful for deploying one set of dashboards across multiple Grafana instances, for instance across environments. 18 | 19 | ## Usage 20 | 21 | > For detailed command help, see the full help text with the `--help` option. 22 | 23 | ### Credentials 24 | 25 | It is important to note that the **admin** login username and password are required, and its selected organization must be correct, if you are accessing the API using `--username` and `--password`. Alternatively, a provided API Key must have **admin** permissions if you are accessing the API using `--token`. 26 | 27 | ### Docker 28 | 29 | A Dockerfile is provided. To build and run: 30 | 31 | ```sh 32 | docker build -t grafana-dashboard-manager:latest . 33 | docker run grafana-dashboard-manager --help 34 | ``` 35 | 36 | ### From PyPI 37 | 38 | Install via _[pip](https://pypi.org/project/pip/)_: 39 | 40 | ```sh 41 | pip install grafana-dashboard-manager 42 | ``` 43 | 44 | ### From source 45 | 46 | Install dependencies and run with _[Poetry](https://python-poetry.org/)_ 47 | 48 | ```sh 49 | cd /path/to/grafana-dashboard-manager 50 | poetry install 51 | poetry run python ./grafana_dashboard_manager --help 52 | ``` 53 | 54 | ## Workflow 55 | 56 | The intended workflow is: 57 | 58 | 1. Download dashboards and to a local directory or version control system for backup and change control. 59 | 1. Replicate across multiple Grafana installs or restore a previous install by uploading the saved dashboards. 60 | 61 | ## Usage Examples 62 | 63 | These examples use `docker run` commands, but the commands are the same regardless of run method. 64 | 65 | Download dashboards using the Grafana admin user: 66 | 67 | ```sh 68 | docker run grafana-dashboard-manager \ 69 | download \ 70 | --scheme https \ 71 | --host my.grafana.example.com \ 72 | --username $USERNAME --password $PASSWORD \ 73 | --destination /path/to/dashboards/ 74 | ``` 75 | 76 | Download dashboards using a Grafana admin API Key: 77 | 78 | ```sh 79 | docker run grafana-dashboard-manager \ 80 | download \ 81 | --scheme https \ 82 | --host my.grafana.example.com \ 83 | --token $API_KEY \ 84 | --destination /path/to/dashboards/ 85 | ``` 86 | 87 | Upload dashboards using the Grafana admin user, to a local instance for testing 88 | 89 | ```sh 90 | docker run grafana-dashboard-manager \ 91 | upload \ 92 | --scheme http \ 93 | --port 3000 \ 94 | --host localhost \ 95 | --username $USERNAME --password $PASSWORD \ 96 | --source /path/to/dashboards/ 97 | ``` 98 | 99 | Upload dashboards using a Grafana admin key without any user prompts: 100 | 101 | ```sh 102 | docker run grafana-dashboard-manager \ 103 | upload \ 104 | --scheme http \ 105 | --port 3000 \ 106 | --host localhost \ 107 | --token $API_KEY \ 108 | --source /path/to/dashboards/ 109 | ``` 110 | 111 | ##  Notes 112 | 113 | - The scheme is `https` and port is 443 by default. If your Grafana is not hosted with https on 443, the scheme and port needs to be specified using the `--scheme` and `--port` options respectively. 114 | - If you use self signed certs on the Grafana server or otherwise don't want to validate an HTTPS connection, use `--skip-verify` although this is not recommended. 115 | - The `version` of the dashboard is removed of the json files in order to allow overwriting and creation of dashboards as new. 116 | - URL encoding of strings is handled by httpx and so characters such as `/` in folder names is supported. 117 | - When uploading, setting the home dashboard from the `home.json` file can be disabled with the option `--skip-home`. 118 | 119 | ## Limitations 120 | 121 | - Does not support the experimental nested folders in Grafana. Only one level of folders is supported. 122 | - Does not support multi-organization deployments. 123 | -------------------------------------------------------------------------------- /grafana_dashboard_manager/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Beam-Connectivity/grafana-dashboard-manager/6be62f07cca87310337f7f3ee32ba08f6ee392a0/grafana_dashboard_manager/__init__.py -------------------------------------------------------------------------------- /grafana_dashboard_manager/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2024 BEAM CONNECTIVITY LIMITED 3 | 4 | Use of this source code is governed by an MIT-style 5 | license that can be found in the LICENSE file or at 6 | https://opensource.org/licenses/MIT. 7 | """ 8 | 9 | import argparse 10 | 11 | from grafana_dashboard_manager.commands import download_dashboards, upload_dashboards 12 | from grafana_dashboard_manager.global_config import GlobalConfig 13 | from grafana_dashboard_manager.grafana import GrafanaApi 14 | from grafana_dashboard_manager.utils import configure_logging, show_info 15 | 16 | 17 | def app(): 18 | """Save and update Grafana dashboards via the HTTP API""" 19 | parser = argparse.ArgumentParser( 20 | description="A cli utility that uses Grafana's HTTP API to easily save and restore dashboards." 21 | ) 22 | 23 | # Parser for common options needed for all commands 24 | parent_parser = argparse.ArgumentParser(add_help=False) 25 | parent_parser.add_argument("--scheme", type=str, default="https", help="http or https") 26 | parent_parser.add_argument("--host", type=str, required=True, help="Grafana host") 27 | parent_parser.add_argument("--port", type=int, default=443, help="Grafana port (default 443)") 28 | parent_parser.add_argument("-u", "--username", type=str, help="Grafana admin login username") 29 | parent_parser.add_argument("-p", "--password", type=str, help="Grafana admin login password") 30 | parent_parser.add_argument("-t", "--token", type=str, help="Grafana API token with admin privileges") 31 | parent_parser.add_argument( 32 | "-o", 33 | "--org", 34 | type=int, 35 | help="An optional property that specifies the organization to which the action is applied.", 36 | ) 37 | parent_parser.add_argument("-v", "--verbose", action="count", default=0, help="Verbosity level") 38 | parent_parser.add_argument( 39 | "--non-interactive", 40 | default=False, 41 | action=argparse.BooleanOptionalAction, 42 | help="Auto-accept confirmation prompts", 43 | ) 44 | parent_parser.add_argument( 45 | "--skip-home", default=False, action=argparse.BooleanOptionalAction, help="Do not set the home dashboard" 46 | ) 47 | parent_parser.add_argument( 48 | "--skip-verify", default=False, action=argparse.BooleanOptionalAction, help="Skip HTTPS server cert validation" 49 | ) 50 | 51 | # Add subcommands 52 | sub_parsers = parser.add_subparsers(title="Commands", required=True, help="Read/Write Dashboard JSONs:") 53 | 54 | # Upload 55 | parser_upload = sub_parsers.add_parser( 56 | "upload", 57 | help="Inserts (and overwrites) dashboard definitions from json files to webapp", 58 | parents=[parent_parser], 59 | ) 60 | parser_upload.add_argument("-s", "--source", required=True, help="Input folder of dashboards") 61 | parser_upload.add_argument("--overwrite", default=False, action=argparse.BooleanOptionalAction) 62 | parser_upload.set_defaults(func=upload_dashboards) 63 | 64 | # Download 65 | parser_download = sub_parsers.add_parser( 66 | "download", help="Retrieve current dashboards from webapp and save to json files", parents=[parent_parser] 67 | ) 68 | parser_download.add_argument("-d", "--destination", required=True, help="Output folder for dashboards") 69 | parser_download.set_defaults(func=download_dashboards) 70 | 71 | args = parser.parse_args() 72 | 73 | configure_logging(args.verbose) 74 | 75 | # Validate the config from the arguments into a known config object 76 | config = GlobalConfig.model_validate(vars(args)) 77 | if args.verbose: 78 | show_info("Config", config.model_dump(exclude={"func"})) 79 | 80 | # API Client 81 | client = GrafanaApi( 82 | scheme=config.scheme, 83 | host=config.host, 84 | port=config.port, 85 | username=config.username, 86 | password=config.password, 87 | token=config.token, 88 | org=config.org, 89 | skip_verify=config.skip_verify, 90 | verbose=args.verbose > 0, 91 | ) 92 | 93 | # Run the desired command 94 | config.func(config, client) 95 | 96 | 97 | if __name__ == "__main__": 98 | app() 99 | -------------------------------------------------------------------------------- /grafana_dashboard_manager/api/auth.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2024 BEAM CONNECTIVITY LIMITED 3 | 4 | Use of this source code is governed by an MIT-style 5 | license that can be found in the LICENSE file or at 6 | https://opensource.org/licenses/MIT. 7 | """ 8 | 9 | from base64 import b64encode 10 | from enum import Enum 11 | 12 | import httpx 13 | 14 | 15 | class GrafanaAuthType(Enum): 16 | """Defines the supported authentication methods""" 17 | 18 | BASIC = "Basic" 19 | BEARER = "Bearer" 20 | 21 | 22 | class GrafanaAuth(httpx.Auth): 23 | """Configures Grafana auth for use with httpx auth""" 24 | 25 | def __init__( 26 | self, 27 | auth_type: GrafanaAuthType, 28 | *, 29 | username: str | None = None, 30 | password: str | None = None, 31 | token: str | None = None, 32 | ): 33 | """ 34 | Creates an auth object which either supports basic auth with username/password, or a bearer token 35 | 36 | Args: 37 | auth_type: enum for auth scheme 38 | username: basic auth username (default: {None}) 39 | password: basic auth password (default: {None}) 40 | token: bearer token auth token (default: {None}) 41 | 42 | Returns: 43 | None 44 | 45 | Raises: 46 | ValueError: if provided args does not satisfy the requirements of auth_type 47 | 48 | """ 49 | self.auth_type = auth_type 50 | self.auth_header_prefix = auth_type.value 51 | 52 | self.username = username 53 | self.password = password 54 | self.token = token 55 | self._credential = None 56 | 57 | match auth_type: 58 | case GrafanaAuthType.BASIC: 59 | if not self.username and self.password: 60 | raise ValueError("Must provide username and password when using Basic Auth") 61 | self._credential = b64encode(f"{self.username}:{self.password}".encode()).decode() 62 | 63 | case GrafanaAuthType.BEARER: 64 | if not self.token: 65 | raise ValueError("Must provide token when using Token Auth") 66 | self._credential = self.token 67 | 68 | case _: 69 | raise ValueError(f"Unsupported auth type: {auth_type}") 70 | 71 | def auth_flow(self, request): 72 | """Required override: https://www.python-httpx.org/advanced/#customizing-authentication""" 73 | request.headers["Authorization"] = f"{self.auth_header_prefix} {self._credential}" 74 | yield request 75 | -------------------------------------------------------------------------------- /grafana_dashboard_manager/api/rest_client.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2024 BEAM CONNECTIVITY LIMITED 3 | 4 | Use of this source code is governed by an MIT-style 5 | license that can be found in the LICENSE file or at 6 | https://opensource.org/licenses/MIT. 7 | """ 8 | 9 | import logging 10 | 11 | import httpx 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class RestClient: 17 | """Provides RESTful calls""" 18 | 19 | def __init__(self, headers, auth, base_url, skip_verify, verbose): 20 | """ 21 | Wrapper on the httpx client to centralise request level exception handling 22 | 23 | Args: 24 | headers: common headers to apply to all requests 25 | auth: an httpx auth object 26 | base_url: url host 27 | skip_verify: set to true to skip verification of https connection certs 28 | verbose: increased logging output 29 | 30 | """ 31 | self.client = httpx.Client(headers=headers, auth=auth, base_url=base_url, verify=skip_verify) 32 | self.verbose = verbose 33 | 34 | def get(self, resource: str) -> httpx.Response: 35 | """HTTP GET""" 36 | return self._make_request("GET", resource) 37 | 38 | def post(self, resource: str, body: dict | None = None) -> httpx.Response: 39 | """HTTP POST""" 40 | return self._make_request("POST", resource, body) 41 | 42 | def put(self, resource: str, body: dict) -> httpx.Response: 43 | """HTTP PUT""" 44 | return self._make_request("PUT", resource, body) 45 | 46 | def patch(self, resource: str, body: dict) -> httpx.Response: 47 | """HTTP PATCH""" 48 | return self._make_request("PATCH", resource, body) 49 | 50 | def delete(self, resource: str) -> httpx.Response: 51 | """HTTP DELETE""" 52 | return self._make_request("DELETE", resource) 53 | 54 | def _make_request(self, verb: str, resource: str, body: dict | None = None) -> httpx.Response: 55 | # Handle connection errors 56 | try: 57 | response = self.client.request(verb, resource, json=body) 58 | except Exception as exc: 59 | if self.verbose: 60 | raise 61 | logger.error(f"Could not connect to {self.client.base_url}{resource}") 62 | logger.error(exc) 63 | exit(1) 64 | 65 | return response 66 | -------------------------------------------------------------------------------- /grafana_dashboard_manager/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from .dashboard_download import download_dashboards 2 | from .dashboard_upload import upload_dashboards 3 | -------------------------------------------------------------------------------- /grafana_dashboard_manager/commands/dashboard_download.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2024 BEAM CONNECTIVITY LIMITED 3 | 4 | Use of this source code is governed by an MIT-style 5 | license that can be found in the LICENSE file or at 6 | https://opensource.org/licenses/MIT. 7 | """ 8 | 9 | import json 10 | import logging 11 | import os 12 | 13 | from grafana_dashboard_manager.global_config import GlobalConfig 14 | from grafana_dashboard_manager.grafana.grafana_api import GrafanaApi 15 | from grafana_dashboard_manager.models import DashboardFolderLookup, DashboardSearchResult 16 | from grafana_dashboard_manager.utils import confirm, show_dashboard_folders 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | def download_dashboards(config: GlobalConfig, client: GrafanaApi): 22 | """Download folder-structured dashboards and write to json files at the destination_root path""" 23 | destination_dir = config.destination 24 | if destination_dir is None: 25 | raise ValueError("No destination directory set") 26 | 27 | # Check destination folder is empty 28 | dest_contents = os.listdir(destination_dir) 29 | destination_is_empty = len(dest_contents) == 0 or (len(dest_contents) == 1 and ".DS_Store" in dest_contents) 30 | 31 | if config.non_interactive or config.overwrite and not destination_is_empty: 32 | logger.warning(f"Potentially overwriting files in {destination_dir}") 33 | if not destination_is_empty and not config.non_interactive: 34 | confirm("Destination directory is not empty. Confirm overwrite?") 35 | 36 | logger.info(f"Pulling all dashboards into {destination_dir}...") 37 | 38 | # Get the folders to replicate locally in the destination_dir 39 | folders = client.folders.all_folders() 40 | 41 | # Keeps track of folders and the dashboards they contain 42 | folder_dashboards: dict[str, DashboardFolderLookup] = { 43 | folder.title: DashboardFolderLookup(id=folder.id, uid=folder.uid, title=folder.title) for folder in folders 44 | } 45 | logger.info(f"Grafana folders found: {', '.join(folder_dashboards.keys())}") 46 | 47 | # Iterate over each folder and grab the dashboards 48 | for folder_title, folder in folder_dashboards.items(): 49 | dashboards = client.folders.dashboards_in_folder(folder.id) 50 | folder_dashboards[folder_title].dashboards.extend( 51 | [DashboardSearchResult.model_validate(dashboard) for dashboard in dashboards] 52 | ) 53 | 54 | show_dashboard_folders(folder_dashboards) 55 | if not config.non_interactive: 56 | confirm(f"Download these dashboard jsons files to '{destination_dir}'?") 57 | 58 | for folder_title, folder in folder_dashboards.items(): 59 | for dashboard in folder.dashboards: 60 | escaped_dashboard_title = dashboard.title.replace("/", "-").replace("\\", "-").replace(" ", "_") 61 | dest_file_path = destination_dir / folder_title / f"{escaped_dashboard_title}.json" 62 | client.dashboards.save(dashboard.uid, dest_file_path) 63 | logger.info(f"Saved {len(folder.dashboards)} dashboards to {destination_dir / folder_title}") 64 | 65 | # Home 66 | client.dashboards.save_home(destination_dir) 67 | 68 | # Store folder information in a folders.json file for use when re-creating folders, we can ensure they have the same 69 | # folderUid 70 | with (destination_dir / "folders.json").open("w") as file: 71 | data = {key: value.model_dump() for key, value in folder_dashboards.items()} 72 | file.write(json.dumps(data, indent=2)) 73 | -------------------------------------------------------------------------------- /grafana_dashboard_manager/commands/dashboard_upload.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2024 BEAM CONNECTIVITY LIMITED 3 | 4 | Use of this source code is governed by an MIT-style 5 | license that can be found in the LICENSE file or at 6 | https://opensource.org/licenses/MIT. 7 | """ 8 | 9 | import json 10 | import logging 11 | from pathlib import Path 12 | 13 | from grafana_dashboard_manager.global_config import GlobalConfig 14 | from grafana_dashboard_manager.grafana.grafana_api import GrafanaApi 15 | from grafana_dashboard_manager.models.folder import Folder 16 | 17 | from ..utils import confirm, show_dashboards 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | def upload_dashboards(config: GlobalConfig, client: GrafanaApi): 23 | """CLI command handler to take a source directory of dashboards and write them to Grafana via the HTTP API""" 24 | source_dir = config.source 25 | 26 | if not isinstance(source_dir, Path): 27 | raise ValueError(f"Unsupported source: {source_dir=}") 28 | 29 | logger.info(f"Uploading dashboards from {source_dir}") 30 | show_dashboards(source_dir) 31 | 32 | # Load the folders.json information if present, but otherwise we can still query for the folders with the caveat 33 | # being folderUids won't be consistent across Grafana installs 34 | folder_info_dir = source_dir / "folders.json" 35 | if not (folder_info_dir.exists() and folder_info_dir.is_file()): 36 | logger.warning(f"The {folder_info_dir} file is missing, which is created when downloading dashboards") 37 | logger.warning("The folders will not have the same folderUid and links/bookmarks will break") 38 | folder_info: dict[str, Folder] = {x.title: Folder.model_validate(x) for x in client.folders.all_folders()} 39 | else: 40 | with folder_info_dir.open("r") as file: 41 | folder_info = {key: Folder.model_validate(value) for key, value in json.loads(file.read()).items()} 42 | 43 | if config.non_interactive is False: 44 | confirm("Folder hierarchy will be preserved. Press any key to confirm upload...") 45 | 46 | for folder in source_dir.glob("*"): 47 | if not folder.is_dir(): 48 | continue 49 | 50 | # Create the folders using a known folderUid, either from the local file or from a live install 51 | known_folder = folder_info.get(folder.name) 52 | 53 | if known_folder: 54 | client.folders.create(folder.name, known_folder.uid) 55 | # For cases where the folder isn't present in either, then we can just create it and use the autogenerated 56 | # folderUid. In this scenario, the source dashboards may contain references to folders which now have a 57 | # different folderUid. 58 | else: 59 | _folder = client.folders.create(folder.name) 60 | folder_info[_folder.title] = _folder 61 | 62 | # Create the folders and dashboards 63 | for json_file in folder.iterdir(): 64 | logger.info(f"{folder.name}: adding dashboard {json_file.name}") 65 | with json_file.open("r") as file: 66 | dashboard = json.loads(file.read()) 67 | 68 | dashboard = update_dashlist_folder_ids(dashboard, folder_info) 69 | client.dashboards.create(dashboard=dashboard, folder_uid=folder_info[folder.name].uid) 70 | 71 | if not config.skip_home: 72 | set_home_dashboard(config, client, folder_info) 73 | else: 74 | logger.info("Skipped setting the home dashboard") 75 | 76 | 77 | def set_home_dashboard(config: GlobalConfig, client: GrafanaApi, folder_info: dict[str, Folder]): 78 | """Uploads the home.json dashboard""" 79 | if config.source is None: 80 | logger.warning("No source directory, cannot find home.json file") 81 | return 82 | 83 | home_dashboard = config.source / "home.json" 84 | 85 | if not home_dashboard.is_file(): 86 | logger.warning(f"{home_dashboard} is not a file") 87 | return 88 | 89 | with home_dashboard.open("r") as file: 90 | dashboard = json.loads(file.read()) 91 | dashboard = update_dashlist_folder_ids(dashboard, folder_info) 92 | 93 | dashboard_uid = client.dashboards.create_home(dashboard) 94 | logger.info(f"Set home dashboard: {dashboard['title']}") 95 | 96 | client.dashboards.set_home(dashboard_uid) 97 | 98 | 99 | def update_dashlist_folder_ids(dashboard: dict, folder_info: dict[str, Folder]) -> dict: 100 | """ 101 | Checks consistency between the id of folders in the database with the dashlist panel definitions, 102 | updating if necessary. 103 | """ 104 | # Some dashboards use a list of "rows" with "panels" nested within, some dashboards just use "panels". 105 | # If using "rows", we need to iterate over "rows" to extract all of the "panels" 106 | if dashboard.get("panels"): 107 | dashboard["panels"] = [update_panel_dashlist_folder_ids(panel, folder_info) for panel in dashboard["panels"]] 108 | 109 | elif dashboard.get("rows"): 110 | for row in dashboard["rows"]: 111 | dashboard["rows"]["panels"] = [ 112 | update_panel_dashlist_folder_ids(panel, folder_info) for panel in row["panels"] 113 | ] 114 | else: 115 | logger.info(f"{dashboard['title']} does not have any any panels") 116 | 117 | return dashboard 118 | 119 | 120 | def update_panel_dashlist_folder_ids(panel: dict, folder_info: dict[str, Folder]) -> dict: 121 | """Updates folder ID/UID for dashlist panels""" 122 | if panel["type"] != "dashlist": 123 | return panel 124 | 125 | folder_name = panel["title"] 126 | folder = folder_info.get(folder_name) 127 | 128 | # If there's no folder, it could be referencing other things like recent dashboards, alerts etc 129 | if folder is None: 130 | logger.debug(f"Panel {folder_name} was not found in folders") 131 | return panel 132 | 133 | # Some dashboard panels may not contain an `options` field 134 | if not panel.get("options"): 135 | logger.warning(f"Panel {folder_name} does not have an options field to modify") 136 | return panel 137 | 138 | # Look up the target folder using the panel title - it needs to match! 139 | logger.info(f"Updating Panel {folder_name} with {folder.uid=} and {folder.id=}") 140 | 141 | # Ensure that the folder id and uid in the dashboard definition matches 142 | if panel_folder_id := panel["options"].get("folderId"): 143 | if panel_folder_id != folder.id: 144 | logger.info(f"Updating folderId to {folder.id}") 145 | panel["options"]["folderId"] = folder.id 146 | 147 | if panel_folder_uid := panel["options"].get("folderUID"): 148 | if panel_folder_uid != folder.uid: 149 | logger.info(f"Updating folderUID to {folder.uid}") 150 | panel["options"]["folderUID"] = folder.uid 151 | 152 | return panel 153 | -------------------------------------------------------------------------------- /grafana_dashboard_manager/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2024 BEAM CONNECTIVITY LIMITED 3 | 4 | Use of this source code is governed by an MIT-style 5 | license that can be found in the LICENSE file or at 6 | https://opensource.org/licenses/MIT. 7 | """ 8 | # ruff: noqa: D101 9 | 10 | 11 | class GrafanaApiException(Exception): 12 | pass 13 | 14 | 15 | class FolderExistsException(Exception): 16 | pass 17 | 18 | 19 | class FolderNotFoundException(Exception): 20 | pass 21 | -------------------------------------------------------------------------------- /grafana_dashboard_manager/global_config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2024 BEAM CONNECTIVITY LIMITED 3 | 4 | Use of this source code is governed by an MIT-style 5 | license that can be found in the LICENSE file or at 6 | https://opensource.org/licenses/MIT. 7 | """ 8 | 9 | import logging 10 | from pathlib import Path 11 | from typing import Callable, Literal 12 | 13 | from pydantic import BaseModel, field_validator 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | def folder_exists(path: Path | str) -> Path: 19 | """Checks if a given path is a folder""" 20 | if isinstance(path, Path): 21 | return path 22 | 23 | _path = Path(path).absolute() 24 | if not _path.is_dir(): 25 | raise ValueError(f"Source path '{path}' does not exist") 26 | 27 | return _path 28 | 29 | 30 | def files_not_more_than_one_folder_deep(path: Path) -> Path: 31 | """Raises exception if there is more than one level of nesting in the given directory path""" 32 | # Iterate through the files and subdirectories in the given directory 33 | for item in path.iterdir(): 34 | if item.is_dir() and any(subdir.is_dir() for subdir in item.iterdir()): 35 | raise ValueError("Nested folders using the nestedFolders feature toggle is not yet supported") 36 | return path 37 | 38 | 39 | class GlobalConfig(BaseModel): 40 | """Holds configuration for all the commands""" 41 | 42 | func: Callable 43 | 44 | scheme: Literal["http", "https"] = "https" 45 | host: str 46 | port: int 47 | 48 | username: str | None = None 49 | password: str | None = None 50 | token: str | None = None 51 | org: int | None = None 52 | skip_verify: bool = False 53 | 54 | non_interactive: bool = False 55 | skip_home: bool = False 56 | 57 | # Upload 58 | source: Path | None = None 59 | overwrite: bool = False 60 | 61 | # Download 62 | destination: Path | None = None 63 | 64 | # Internal 65 | home_dashboard: bool = False 66 | 67 | @field_validator("host") 68 | @classmethod 69 | def strip_trailing_slash(cls, host: str) -> str: 70 | """Pydantic validator to remove trailing slashes entered with the --host option""" 71 | if host[-1] == "/": 72 | host = host[:-1] 73 | return host 74 | 75 | @field_validator("source", "destination") 76 | @classmethod 77 | def folder_exists_if_not_none(cls, path: Path | None) -> Path | None: 78 | """Pydantic validator to check given directories exist""" 79 | if path is None: 80 | return path 81 | 82 | path = folder_exists(path) 83 | 84 | return path 85 | 86 | @field_validator("source") 87 | @classmethod 88 | def validate_source_folder(cls, path: Path | None) -> Path | None: 89 | """Pydantic validator to check depth of source folder. Currently does not support multiple level folders""" 90 | if path is None: 91 | return path 92 | 93 | path = files_not_more_than_one_folder_deep(path) 94 | 95 | return path 96 | 97 | @field_validator("source") 98 | @classmethod 99 | def validate_source_contains_home_dashboard(cls, path: Path | None) -> Path | None: 100 | """Ensures the home dashboard exists""" 101 | if path is None: 102 | return path 103 | 104 | # Iterate over the contents of the directory 105 | for file_path in path.iterdir(): 106 | # Check if the file is named 'home.json' 107 | if file_path.name == "home.json": 108 | # If found, exit ok 109 | logger.info("Found home.json in root of source directory") 110 | cls.home_dashboard = True 111 | break 112 | else: 113 | # If 'home.json' is not found, raise a ValueError 114 | logger.warning("'home.json' not found in root of source directory, will skip setting home dashboard") 115 | 116 | return path 117 | -------------------------------------------------------------------------------- /grafana_dashboard_manager/grafana/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2024 BEAM CONNECTIVITY LIMITED 3 | 4 | Use of this source code is governed by an MIT-style 5 | license that can be found in the LICENSE file or at 6 | https://opensource.org/licenses/MIT. 7 | """ 8 | 9 | from .grafana_api import GrafanaApi, GrafanaAuthType 10 | -------------------------------------------------------------------------------- /grafana_dashboard_manager/grafana/grafana_api.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2024 BEAM CONNECTIVITY LIMITED 3 | 4 | Use of this source code is governed by an MIT-style 5 | license that can be found in the LICENSE file or at 6 | https://opensource.org/licenses/MIT. 7 | """ 8 | 9 | import logging 10 | 11 | from grafana_dashboard_manager.api.auth import GrafanaAuth, GrafanaAuthType 12 | from grafana_dashboard_manager.api.rest_client import RestClient 13 | from grafana_dashboard_manager.handlers.api_dashboards import ApiDashboards 14 | from grafana_dashboard_manager.handlers.api_folders import ApiFolders 15 | 16 | logger = logging.getLogger() 17 | 18 | 19 | class GrafanaApi: 20 | """HTTP REST calls with status code checking and common auth/headers""" 21 | 22 | CLIENT_HEADERS = { 23 | "Accept": "application/json; charset=UTF-8", 24 | "Content-Type": "application/json", 25 | } 26 | 27 | def __init__( 28 | self, 29 | scheme: str, 30 | host: str, 31 | port: int, 32 | username: str | None = None, 33 | password: str | None = None, 34 | token: str | None = None, 35 | org: int | None = None, 36 | skip_verify: bool = False, 37 | verbose: bool = False, 38 | ) -> None: 39 | """Wrapper object to interact with Grafana entities like Folders and Dashboards via the HTTP API""" 40 | self.host = f"{scheme}://{host}:{port}" 41 | 42 | # Set the X-Grafana-Org-Id header if this request is for a given organization 43 | if org: 44 | self.CLIENT_HEADERS.update({"X-Grafana-Org-Id": str(org)}) 45 | 46 | self._api = RestClient( 47 | self.CLIENT_HEADERS, 48 | self._init_auth(token, username, password), 49 | f"{self.host}/api/", 50 | skip_verify, 51 | verbose, 52 | ) 53 | 54 | self.folders = ApiFolders(self._api) 55 | self.dashboards = ApiDashboards(self._api) 56 | 57 | def _init_auth(self, token, username, password): 58 | if token: 59 | logger.info("Using Bearer token header auth") 60 | auth = GrafanaAuth(GrafanaAuthType.BEARER, token=token) 61 | elif username and password: 62 | logger.info(f"Using Basic auth with user '{username}'") 63 | auth = GrafanaAuth(GrafanaAuthType.BASIC, username=username, password=password) 64 | else: 65 | raise ValueError("Supply either a bearer token or username/password for basic auth") 66 | 67 | return auth 68 | -------------------------------------------------------------------------------- /grafana_dashboard_manager/handlers/api_dashboards.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2024 BEAM CONNECTIVITY LIMITED 3 | 4 | Use of this source code is governed by an MIT-style 5 | license that can be found in the LICENSE file or at 6 | https://opensource.org/licenses/MIT. 7 | """ 8 | 9 | import json 10 | import logging 11 | from datetime import datetime 12 | from pathlib import Path 13 | 14 | import httpx 15 | 16 | from grafana_dashboard_manager.api.rest_client import RestClient 17 | from grafana_dashboard_manager.exceptions import GrafanaApiException 18 | from grafana_dashboard_manager.handlers.base_handler import BaseHandler 19 | from grafana_dashboard_manager.models import DashboardResponse 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | class ApiDashboards(BaseHandler): 25 | """Handler class to interact with Dashboards via API""" 26 | 27 | model = DashboardResponse 28 | 29 | def __init__(self, api: RestClient): 30 | """Provide a RestClient to use for API calls""" 31 | self.api = api 32 | 33 | def by_id(self, id: int) -> DashboardResponse: 34 | """Get a dashboard with a dashboard ID int""" 35 | response = self.api.get(f"dashboards/id/{id}") 36 | return self._response_to_model(response) 37 | 38 | def by_uid(self, uid: str) -> DashboardResponse: 39 | """Get a dashboard with a dashboard UID string""" 40 | response = self.api.get(f"dashboards/uid/{uid}") 41 | return self._response_to_model(response) 42 | 43 | def save(self, uid: str, file: Path) -> None: 44 | """Download a dashboard to a local path""" 45 | file.parent.mkdir(parents=True, exist_ok=True) 46 | 47 | dashboard = self.api.get(f"dashboards/uid/{uid}").json()["dashboard"] 48 | self._write_json(dashboard, file) 49 | 50 | def save_home(self, directory: Path) -> None: 51 | """Download the home dashboard""" 52 | dest_file = directory / "home.json" 53 | response = self.api.get("dashboards/home").json() 54 | 55 | # If the dashboard has been set to a custom dashboard, the response will be a direct to that dashboard 56 | if "redirectUri" in response: 57 | home_uid = response["redirectUri"].split("/")[2] 58 | logger.info(f"Custom home dashboard has been set: {home_uid=} and saved to {dest_file}") 59 | dashboard = self.api.get(f"dashboards/uid/{home_uid}").json()["dashboard"] 60 | else: 61 | dashboard = response["dashboard"] 62 | 63 | self._write_json(dashboard, dest_file) 64 | 65 | def create(self, dashboard: dict, folder_uid: str | None = None, overwrite: bool = True) -> None: 66 | """Create a new dashboard""" 67 | dashboard.pop("id", None) 68 | 69 | if not folder_uid: 70 | logger.warning(f"Dashboard {dashboard['title']} has no folder and will be added at the root level") 71 | payload = { 72 | "dashboard": dashboard, 73 | "folderUid": folder_uid, 74 | "message": f"Uploaded at {datetime.now()}", 75 | "overwrite": overwrite, 76 | } 77 | 78 | response = self.api.post("dashboards/db", body=payload) 79 | 80 | if response.status_code != 200: 81 | logger.error(f"Failed to upload {dashboard['title']} - {response.json()}") 82 | 83 | def create_home(self, dashboard: dict) -> str: 84 | """Create the home dashboard (store in the default General folder by convention)""" 85 | dashboard.pop("id", None) 86 | 87 | response = self.api.post( 88 | "dashboards/db", 89 | body={ 90 | "dashboard": dashboard, 91 | "folderId": 0, 92 | "message": f"Uploaded at {datetime.now()}", 93 | "overwrite": True, 94 | }, 95 | ) 96 | 97 | if response.status_code != 200: 98 | raise GrafanaApiException( 99 | f"{response.status_code}: Failed to upload {dashboard['title']} - {response.json()}" 100 | ) 101 | 102 | return response.json()["uid"] 103 | 104 | def set_home(self, uid: str) -> None: 105 | """Set a given dashboard as the default home dashboard for the current organization""" 106 | response = self.api.patch("/org/preferences", {"homeDashboardUID": uid}) 107 | if response.status_code != 200: 108 | raise GrafanaApiException(f"Failed to set home dashboard {response.json()}") 109 | 110 | def _write_json(self, data: dict, path: Path): 111 | with path.open("w") as f: 112 | f.write(json.dumps(data, indent=4)) 113 | 114 | def _response_to_model(self, response: httpx.Response): 115 | body = response.json() 116 | folder = self.model.model_validate(body) 117 | return folder 118 | -------------------------------------------------------------------------------- /grafana_dashboard_manager/handlers/api_folders.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2024 BEAM CONNECTIVITY LIMITED 3 | 4 | Use of this source code is governed by an MIT-style 5 | license that can be found in the LICENSE file or at 6 | https://opensource.org/licenses/MIT. 7 | """ 8 | 9 | import logging 10 | from typing import Type 11 | 12 | from grafana_dashboard_manager.api.rest_client import RestClient 13 | from grafana_dashboard_manager.exceptions import FolderExistsException, FolderNotFoundException, GrafanaApiException 14 | from grafana_dashboard_manager.handlers.base_handler import BaseHandler 15 | from grafana_dashboard_manager.models import DashboardSearchResult, Folder 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | class ApiFolders(BaseHandler[Folder]): 21 | """Handler class to interact with Folders via API""" 22 | 23 | model: Type[Folder] = Folder 24 | 25 | def __init__(self, api: RestClient): 26 | """Provide a RestClient to use for API calls""" 27 | self.api = api 28 | 29 | def all_folders(self) -> list[Folder]: 30 | """Get a list of all folders""" 31 | response = self.api.get("folders") 32 | body = response.json() 33 | return [Folder.model_validate(folder) for folder in body] 34 | 35 | def dashboards_in_folder(self, folder_id: int) -> list[DashboardSearchResult]: 36 | """Get a list of all dashboards within a given folder""" 37 | response = self.api.get(f"search?folderIds={folder_id}") 38 | body = response.json() 39 | return [DashboardSearchResult.model_validate(dashboard) for dashboard in body] 40 | 41 | def general_folder(self) -> Folder: 42 | """Get details for the default General folder""" 43 | return self.by_id(0) 44 | 45 | def by_uid(self, uid: str) -> Folder: 46 | """Get folder details by its folderUid""" 47 | response = self.api.get(f"folders/uid/{uid}") 48 | return self.response_to_model(response) 49 | 50 | def by_id(self, id: int) -> Folder: 51 | """Get folder details by its folderId""" 52 | response = self.api.get(f"folders/id/{id}") 53 | return self.response_to_model(response) 54 | 55 | def by_name(self, name: str) -> Folder: 56 | """Get folder details by its name (title)""" 57 | response = self.api.get(f"search?type=dash-folder&query={name}") 58 | try: 59 | return self.model.model_validate(response.json()[0]) 60 | except IndexError as exc: 61 | raise FolderNotFoundException(f"No results for folder with {name=}") from exc 62 | 63 | def create(self, title: str, uid: str | None = None, *, overwrite: bool = True) -> Folder: 64 | """Create a new dashboard""" 65 | body = { 66 | "uid": uid, 67 | "title": title, 68 | } 69 | response = self.api.post("folders", body) 70 | 71 | if response.status_code not in {200, 409, 412}: 72 | raise GrafanaApiException(f"Could not update folder '{title}': {response.json()}") 73 | 74 | else: 75 | if response.status_code == 200: 76 | response_uid = response.json().get("uid") 77 | logger.info(f"Created folder with title '{title}' with uid={response_uid}") 78 | return self.response_to_model(response) 79 | 80 | # Retry with PUT (i.e. update) 81 | if overwrite is False: 82 | raise FolderExistsException(f"Folder already exists: {title=} {uid=}. Use overwrite option") 83 | 84 | response = self.api.put(f"folders/{uid}", {**body, "overwrite": True}) 85 | if response.status_code == 200: 86 | response_uid = response.json().get("uid") 87 | logger.info(f"Updated folder title to '{title}' (uid={response_uid})") 88 | return self.response_to_model(response) 89 | else: 90 | raise GrafanaApiException(f"Could not update folder '{title}': {response.json()}") 91 | -------------------------------------------------------------------------------- /grafana_dashboard_manager/handlers/base_handler.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2024 BEAM CONNECTIVITY LIMITED 3 | 4 | Use of this source code is governed by an MIT-style 5 | license that can be found in the LICENSE file or at 6 | https://opensource.org/licenses/MIT. 7 | """ 8 | 9 | import logging 10 | from typing import Generic, TypeVar 11 | 12 | import httpx 13 | from pydantic import BaseModel 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | T = TypeVar("T", bound=BaseModel) 19 | 20 | 21 | class BaseHandler(Generic[T]): 22 | """Common parent class for handler classes""" 23 | 24 | model: T 25 | 26 | def check_response(self, response: httpx.Response): 27 | """Logger wrapper for Grafana's HTTP responses""" 28 | match response.status_code: 29 | case 200: 30 | logger.debug(f"200 success for {response.request.url}") 31 | return 32 | case 400: 33 | msg = f"400: Request contains errors: {response.json()}" 34 | case 401: 35 | msg = f"401: Unauthorized: {response.json()}" 36 | case 403: 37 | msg = f"403: Access Denied: {response.json()}" 38 | case 412: 39 | msg = f"412: Precondition failed: {response.json()}" 40 | case 500 | 501 | 502 | 503 | 504: 41 | msg = f"5xx Server Error: {response.json()}" 42 | case _: 43 | msg = f"Unknown Error: {response.json()}" 44 | 45 | logger.error(f"HTTP error for {response.request.url} - {msg}") 46 | 47 | def response_to_model(self, response: httpx.Response): 48 | """Convert the HTTP response into the entity model""" 49 | body = response.json() 50 | folder = self.model.model_validate(body) 51 | return folder 52 | -------------------------------------------------------------------------------- /grafana_dashboard_manager/models/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2024 BEAM CONNECTIVITY LIMITED 3 | 4 | Use of this source code is governed by an MIT-style 5 | license that can be found in the LICENSE file or at 6 | https://opensource.org/licenses/MIT. 7 | """ 8 | 9 | from .dashboard import DashboardFolderLookup, DashboardResponse, DashboardSearchResult, FolderDashboards 10 | from .folder import Folder, FolderDetails 11 | -------------------------------------------------------------------------------- /grafana_dashboard_manager/models/dashboard.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2024 BEAM CONNECTIVITY LIMITED 3 | 4 | Use of this source code is governed by an MIT-style 5 | license that can be found in the LICENSE file or at 6 | https://opensource.org/licenses/MIT. 7 | """ 8 | 9 | # ruff: noqa: D101 10 | from typing import Literal 11 | 12 | from pydantic import BaseModel 13 | 14 | 15 | class DashboardBase(BaseModel): 16 | id: int 17 | uid: str 18 | title: str 19 | 20 | 21 | class Dashboard(DashboardBase): 22 | tags: list[str] 23 | timezone: str 24 | schemaVersion: int 25 | version: int 26 | 27 | 28 | class DashboardMeta(BaseModel): 29 | isStarred: bool 30 | url: str 31 | folderId: int 32 | folderUid: str 33 | slug: str 34 | 35 | 36 | class DashboardSearchResult(BaseModel): 37 | id: int 38 | uid: str 39 | title: str 40 | uri: str 41 | url: str 42 | slug: str 43 | type: Literal["dash-db"] 44 | tags: list[str] 45 | isStarred: bool 46 | folderId: int 47 | folderUid: str 48 | folderTitle: str 49 | folderUrl: str 50 | sortMeta: int 51 | 52 | 53 | class DashboardFolderLookup(BaseModel): 54 | uid: str 55 | id: int 56 | title: str 57 | dashboards: list[DashboardSearchResult] = [] 58 | 59 | 60 | class FolderDashboards(BaseModel): 61 | folder_title: str 62 | dashboards: list[DashboardFolderLookup] 63 | 64 | 65 | class DashboardResponse(BaseModel): 66 | dashboard: Dashboard 67 | meta: DashboardMeta 68 | -------------------------------------------------------------------------------- /grafana_dashboard_manager/models/folder.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2024 BEAM CONNECTIVITY LIMITED 3 | 4 | Use of this source code is governed by an MIT-style 5 | license that can be found in the LICENSE file or at 6 | https://opensource.org/licenses/MIT. 7 | """ 8 | 9 | # ruff: noqa: D101 10 | from datetime import datetime 11 | 12 | from pydantic import BaseModel 13 | 14 | 15 | class Folder(BaseModel): 16 | id: int 17 | uid: str 18 | title: str 19 | 20 | 21 | class FolderDetails(Folder): 22 | url: str 23 | hasAcl: bool 24 | canSave: bool 25 | canEdit: bool 26 | canAdmin: bool 27 | createdBy: str 28 | created: datetime 29 | updatedBy: str 30 | updated: datetime 31 | version: int 32 | -------------------------------------------------------------------------------- /grafana_dashboard_manager/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2024 BEAM CONNECTIVITY LIMITED 3 | 4 | Use of this source code is governed by an MIT-style 5 | license that can be found in the LICENSE file or at 6 | https://opensource.org/licenses/MIT. 7 | """ 8 | 9 | import logging 10 | from pathlib import Path 11 | 12 | import rich 13 | from rich.filesize import decimal 14 | from rich.logging import RichHandler 15 | from rich.markup import escape 16 | from rich.panel import Panel 17 | from rich.pretty import Pretty 18 | from rich.prompt import Confirm 19 | from rich.text import Text 20 | from rich.traceback import install 21 | from rich.tree import Tree 22 | 23 | from grafana_dashboard_manager.models import DashboardFolderLookup 24 | 25 | logger = logging.getLogger(__name__) 26 | 27 | 28 | def configure_logging(verbose: int): 29 | """Sets up the python logging format and level""" 30 | install(show_locals=False) 31 | log_level = "DEBUG" if verbose > 0 else "INFO" 32 | logging.basicConfig( 33 | level=log_level, format="%(message)s", datefmt="[%X]", handlers=[RichHandler(rich_tracebacks=True)] 34 | ) 35 | logging.log(logging.getLevelName(log_level), f"Logging level is set to {log_level}") 36 | 37 | # In normal usage, don't log every http request 38 | if log_level == "INFO": 39 | logging.getLogger("httpx").setLevel(logging.WARNING) 40 | 41 | 42 | def confirm(user_prompt: str): 43 | """A user interactive call to confirm an action""" 44 | should_continue = Confirm.ask(user_prompt) 45 | if not should_continue: 46 | logger.info("Aborted") 47 | exit(0) 48 | 49 | 50 | def walk_directory(directory: Path, tree: Tree) -> Tree: 51 | """Recursively build a Tree with directory contents.""" 52 | # Sort dirs first then by filename 53 | paths = sorted(Path(directory).iterdir(), key=lambda path: (path.is_file(), path.name.lower())) 54 | for path in paths: 55 | # Remove hidden files 56 | if path.name.startswith("."): 57 | continue 58 | if path.is_dir(): 59 | branch = tree.add(f"[bold magenta]:open_file_folder: [link file://{path}]{escape(path.name)}") 60 | walk_directory(path, branch) 61 | else: 62 | text_filename = Text(path.name, "green") 63 | text_filename.stylize(f"link file://{path}") 64 | file_size = path.stat().st_size 65 | text_filename.append(f" ({decimal(file_size)})", "blue") 66 | tree.add(Text("📄 ") + text_filename) 67 | 68 | return tree 69 | 70 | 71 | def show_dashboards(source_dir: Path) -> None: 72 | """Display a tree hierarchy of a folder of dashboards on the local disk""" 73 | tree = Tree(f":open_file_folder: [link file://{source_dir}]{source_dir}", guide_style="bold bright_blue") 74 | tree = walk_directory(source_dir, tree) 75 | rich.print(Panel(tree, title="Dashboards:")) 76 | 77 | 78 | def show_dashboard_folders(source: dict[str, DashboardFolderLookup]) -> None: 79 | """Displays a tree hierarchy of a dict of DashboardFolderLookup objects""" 80 | tree = Tree("Grafana Root:") 81 | for title, contents in source.items(): 82 | folder_branch = tree.add(f"📂 {title}", style="bold green") 83 | for dashboard in contents.dashboards: 84 | folder_branch.add(f"📄 {dashboard.title} (uid={dashboard.uid})", style="default") 85 | rich.print(Panel(tree, title="Dashboards")) 86 | 87 | 88 | def show_info(title: str, data: dict) -> None: 89 | """Wraps input data in a panel""" 90 | rich.print(Panel(Pretty(data), title=title)) 91 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "annotated-types" 5 | version = "0.6.0" 6 | description = "Reusable constraint types to use with typing.Annotated" 7 | optional = false 8 | python-versions = ">=3.8" 9 | files = [ 10 | {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"}, 11 | {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, 12 | ] 13 | 14 | [[package]] 15 | name = "anyio" 16 | version = "4.3.0" 17 | description = "High level compatibility layer for multiple asynchronous event loop implementations" 18 | optional = false 19 | python-versions = ">=3.8" 20 | files = [ 21 | {file = "anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8"}, 22 | {file = "anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6"}, 23 | ] 24 | 25 | [package.dependencies] 26 | idna = ">=2.8" 27 | sniffio = ">=1.1" 28 | 29 | [package.extras] 30 | doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] 31 | test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] 32 | trio = ["trio (>=0.23)"] 33 | 34 | [[package]] 35 | name = "certifi" 36 | version = "2024.2.2" 37 | description = "Python package for providing Mozilla's CA Bundle." 38 | optional = false 39 | python-versions = ">=3.6" 40 | files = [ 41 | {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, 42 | {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, 43 | ] 44 | 45 | [[package]] 46 | name = "colorama" 47 | version = "0.4.6" 48 | description = "Cross-platform colored terminal text." 49 | optional = false 50 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 51 | files = [ 52 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 53 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 54 | ] 55 | 56 | [[package]] 57 | name = "h11" 58 | version = "0.14.0" 59 | description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" 60 | optional = false 61 | python-versions = ">=3.7" 62 | files = [ 63 | {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, 64 | {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, 65 | ] 66 | 67 | [[package]] 68 | name = "httpcore" 69 | version = "1.0.5" 70 | description = "A minimal low-level HTTP client." 71 | optional = false 72 | python-versions = ">=3.8" 73 | files = [ 74 | {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, 75 | {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, 76 | ] 77 | 78 | [package.dependencies] 79 | certifi = "*" 80 | h11 = ">=0.13,<0.15" 81 | 82 | [package.extras] 83 | asyncio = ["anyio (>=4.0,<5.0)"] 84 | http2 = ["h2 (>=3,<5)"] 85 | socks = ["socksio (==1.*)"] 86 | trio = ["trio (>=0.22.0,<0.26.0)"] 87 | 88 | [[package]] 89 | name = "httpx" 90 | version = "0.27.0" 91 | description = "The next generation HTTP client." 92 | optional = false 93 | python-versions = ">=3.8" 94 | files = [ 95 | {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, 96 | {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, 97 | ] 98 | 99 | [package.dependencies] 100 | anyio = "*" 101 | certifi = "*" 102 | httpcore = "==1.*" 103 | idna = "*" 104 | sniffio = "*" 105 | 106 | [package.extras] 107 | brotli = ["brotli", "brotlicffi"] 108 | cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] 109 | http2 = ["h2 (>=3,<5)"] 110 | socks = ["socksio (==1.*)"] 111 | 112 | [[package]] 113 | name = "idna" 114 | version = "3.6" 115 | description = "Internationalized Domain Names in Applications (IDNA)" 116 | optional = false 117 | python-versions = ">=3.5" 118 | files = [ 119 | {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, 120 | {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, 121 | ] 122 | 123 | [[package]] 124 | name = "iniconfig" 125 | version = "2.0.0" 126 | description = "brain-dead simple config-ini parsing" 127 | optional = false 128 | python-versions = ">=3.7" 129 | files = [ 130 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 131 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 132 | ] 133 | 134 | [[package]] 135 | name = "markdown-it-py" 136 | version = "3.0.0" 137 | description = "Python port of markdown-it. Markdown parsing, done right!" 138 | optional = false 139 | python-versions = ">=3.8" 140 | files = [ 141 | {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, 142 | {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, 143 | ] 144 | 145 | [package.dependencies] 146 | mdurl = ">=0.1,<1.0" 147 | 148 | [package.extras] 149 | benchmarking = ["psutil", "pytest", "pytest-benchmark"] 150 | code-style = ["pre-commit (>=3.0,<4.0)"] 151 | compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] 152 | linkify = ["linkify-it-py (>=1,<3)"] 153 | plugins = ["mdit-py-plugins"] 154 | profiling = ["gprof2dot"] 155 | rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] 156 | testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] 157 | 158 | [[package]] 159 | name = "mdurl" 160 | version = "0.1.2" 161 | description = "Markdown URL utilities" 162 | optional = false 163 | python-versions = ">=3.7" 164 | files = [ 165 | {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, 166 | {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, 167 | ] 168 | 169 | [[package]] 170 | name = "packaging" 171 | version = "24.0" 172 | description = "Core utilities for Python packages" 173 | optional = false 174 | python-versions = ">=3.7" 175 | files = [ 176 | {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, 177 | {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, 178 | ] 179 | 180 | [[package]] 181 | name = "pluggy" 182 | version = "1.4.0" 183 | description = "plugin and hook calling mechanisms for python" 184 | optional = false 185 | python-versions = ">=3.8" 186 | files = [ 187 | {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, 188 | {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, 189 | ] 190 | 191 | [package.extras] 192 | dev = ["pre-commit", "tox"] 193 | testing = ["pytest", "pytest-benchmark"] 194 | 195 | [[package]] 196 | name = "pydantic" 197 | version = "2.6.4" 198 | description = "Data validation using Python type hints" 199 | optional = false 200 | python-versions = ">=3.8" 201 | files = [ 202 | {file = "pydantic-2.6.4-py3-none-any.whl", hash = "sha256:cc46fce86607580867bdc3361ad462bab9c222ef042d3da86f2fb333e1d916c5"}, 203 | {file = "pydantic-2.6.4.tar.gz", hash = "sha256:b1704e0847db01817624a6b86766967f552dd9dbf3afba4004409f908dcc84e6"}, 204 | ] 205 | 206 | [package.dependencies] 207 | annotated-types = ">=0.4.0" 208 | pydantic-core = "2.16.3" 209 | typing-extensions = ">=4.6.1" 210 | 211 | [package.extras] 212 | email = ["email-validator (>=2.0.0)"] 213 | 214 | [[package]] 215 | name = "pydantic-core" 216 | version = "2.16.3" 217 | description = "" 218 | optional = false 219 | python-versions = ">=3.8" 220 | files = [ 221 | {file = "pydantic_core-2.16.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:75b81e678d1c1ede0785c7f46690621e4c6e63ccd9192af1f0bd9d504bbb6bf4"}, 222 | {file = "pydantic_core-2.16.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9c865a7ee6f93783bd5d781af5a4c43dadc37053a5b42f7d18dc019f8c9d2bd1"}, 223 | {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:162e498303d2b1c036b957a1278fa0899d02b2842f1ff901b6395104c5554a45"}, 224 | {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2f583bd01bbfbff4eaee0868e6fc607efdfcc2b03c1c766b06a707abbc856187"}, 225 | {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b926dd38db1519ed3043a4de50214e0d600d404099c3392f098a7f9d75029ff8"}, 226 | {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:716b542728d4c742353448765aa7cdaa519a7b82f9564130e2b3f6766018c9ec"}, 227 | {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ad7f7ee1a13d9cb49d8198cd7d7e3aa93e425f371a68235f784e99741561f"}, 228 | {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bd87f48924f360e5d1c5f770d6155ce0e7d83f7b4e10c2f9ec001c73cf475c99"}, 229 | {file = "pydantic_core-2.16.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0df446663464884297c793874573549229f9eca73b59360878f382a0fc085979"}, 230 | {file = "pydantic_core-2.16.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4df8a199d9f6afc5ae9a65f8f95ee52cae389a8c6b20163762bde0426275b7db"}, 231 | {file = "pydantic_core-2.16.3-cp310-none-win32.whl", hash = "sha256:456855f57b413f077dff513a5a28ed838dbbb15082ba00f80750377eed23d132"}, 232 | {file = "pydantic_core-2.16.3-cp310-none-win_amd64.whl", hash = "sha256:732da3243e1b8d3eab8c6ae23ae6a58548849d2e4a4e03a1924c8ddf71a387cb"}, 233 | {file = "pydantic_core-2.16.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:519ae0312616026bf4cedc0fe459e982734f3ca82ee8c7246c19b650b60a5ee4"}, 234 | {file = "pydantic_core-2.16.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b3992a322a5617ded0a9f23fd06dbc1e4bd7cf39bc4ccf344b10f80af58beacd"}, 235 | {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d62da299c6ecb04df729e4b5c52dc0d53f4f8430b4492b93aa8de1f541c4aac"}, 236 | {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2acca2be4bb2f2147ada8cac612f8a98fc09f41c89f87add7256ad27332c2fda"}, 237 | {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b662180108c55dfbf1280d865b2d116633d436cfc0bba82323554873967b340"}, 238 | {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e7c6ed0dc9d8e65f24f5824291550139fe6f37fac03788d4580da0d33bc00c97"}, 239 | {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1bb0827f56654b4437955555dc3aeeebeddc47c2d7ed575477f082622c49e"}, 240 | {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e56f8186d6210ac7ece503193ec84104da7ceb98f68ce18c07282fcc2452e76f"}, 241 | {file = "pydantic_core-2.16.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:936e5db01dd49476fa8f4383c259b8b1303d5dd5fb34c97de194560698cc2c5e"}, 242 | {file = "pydantic_core-2.16.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:33809aebac276089b78db106ee692bdc9044710e26f24a9a2eaa35a0f9fa70ba"}, 243 | {file = "pydantic_core-2.16.3-cp311-none-win32.whl", hash = "sha256:ded1c35f15c9dea16ead9bffcde9bb5c7c031bff076355dc58dcb1cb436c4721"}, 244 | {file = "pydantic_core-2.16.3-cp311-none-win_amd64.whl", hash = "sha256:d89ca19cdd0dd5f31606a9329e309d4fcbb3df860960acec32630297d61820df"}, 245 | {file = "pydantic_core-2.16.3-cp311-none-win_arm64.whl", hash = "sha256:6162f8d2dc27ba21027f261e4fa26f8bcb3cf9784b7f9499466a311ac284b5b9"}, 246 | {file = "pydantic_core-2.16.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0f56ae86b60ea987ae8bcd6654a887238fd53d1384f9b222ac457070b7ac4cff"}, 247 | {file = "pydantic_core-2.16.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9bd22a2a639e26171068f8ebb5400ce2c1bc7d17959f60a3b753ae13c632975"}, 248 | {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4204e773b4b408062960e65468d5346bdfe139247ee5f1ca2a378983e11388a2"}, 249 | {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f651dd19363c632f4abe3480a7c87a9773be27cfe1341aef06e8759599454120"}, 250 | {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aaf09e615a0bf98d406657e0008e4a8701b11481840be7d31755dc9f97c44053"}, 251 | {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8e47755d8152c1ab5b55928ab422a76e2e7b22b5ed8e90a7d584268dd49e9c6b"}, 252 | {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:500960cb3a0543a724a81ba859da816e8cf01b0e6aaeedf2c3775d12ee49cade"}, 253 | {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf6204fe865da605285c34cf1172879d0314ff267b1c35ff59de7154f35fdc2e"}, 254 | {file = "pydantic_core-2.16.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d33dd21f572545649f90c38c227cc8631268ba25c460b5569abebdd0ec5974ca"}, 255 | {file = "pydantic_core-2.16.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:49d5d58abd4b83fb8ce763be7794d09b2f50f10aa65c0f0c1696c677edeb7cbf"}, 256 | {file = "pydantic_core-2.16.3-cp312-none-win32.whl", hash = "sha256:f53aace168a2a10582e570b7736cc5bef12cae9cf21775e3eafac597e8551fbe"}, 257 | {file = "pydantic_core-2.16.3-cp312-none-win_amd64.whl", hash = "sha256:0d32576b1de5a30d9a97f300cc6a3f4694c428d956adbc7e6e2f9cad279e45ed"}, 258 | {file = "pydantic_core-2.16.3-cp312-none-win_arm64.whl", hash = "sha256:ec08be75bb268473677edb83ba71e7e74b43c008e4a7b1907c6d57e940bf34b6"}, 259 | {file = "pydantic_core-2.16.3-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:b1f6f5938d63c6139860f044e2538baeee6f0b251a1816e7adb6cbce106a1f01"}, 260 | {file = "pydantic_core-2.16.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2a1ef6a36fdbf71538142ed604ad19b82f67b05749512e47f247a6ddd06afdc7"}, 261 | {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:704d35ecc7e9c31d48926150afada60401c55efa3b46cd1ded5a01bdffaf1d48"}, 262 | {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d937653a696465677ed583124b94a4b2d79f5e30b2c46115a68e482c6a591c8a"}, 263 | {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9803edf8e29bd825f43481f19c37f50d2b01899448273b3a7758441b512acf8"}, 264 | {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:72282ad4892a9fb2da25defeac8c2e84352c108705c972db82ab121d15f14e6d"}, 265 | {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f752826b5b8361193df55afcdf8ca6a57d0232653494ba473630a83ba50d8c9"}, 266 | {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4384a8f68ddb31a0b0c3deae88765f5868a1b9148939c3f4121233314ad5532c"}, 267 | {file = "pydantic_core-2.16.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a4b2bf78342c40b3dc830880106f54328928ff03e357935ad26c7128bbd66ce8"}, 268 | {file = "pydantic_core-2.16.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:13dcc4802961b5f843a9385fc821a0b0135e8c07fc3d9949fd49627c1a5e6ae5"}, 269 | {file = "pydantic_core-2.16.3-cp38-none-win32.whl", hash = "sha256:e3e70c94a0c3841e6aa831edab1619ad5c511199be94d0c11ba75fe06efe107a"}, 270 | {file = "pydantic_core-2.16.3-cp38-none-win_amd64.whl", hash = "sha256:ecdf6bf5f578615f2e985a5e1f6572e23aa632c4bd1dc67f8f406d445ac115ed"}, 271 | {file = "pydantic_core-2.16.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:bda1ee3e08252b8d41fa5537413ffdddd58fa73107171a126d3b9ff001b9b820"}, 272 | {file = "pydantic_core-2.16.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:21b888c973e4f26b7a96491c0965a8a312e13be108022ee510248fe379a5fa23"}, 273 | {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be0ec334369316fa73448cc8c982c01e5d2a81c95969d58b8f6e272884df0074"}, 274 | {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5b6079cc452a7c53dd378c6f881ac528246b3ac9aae0f8eef98498a75657805"}, 275 | {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ee8d5f878dccb6d499ba4d30d757111847b6849ae07acdd1205fffa1fc1253c"}, 276 | {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7233d65d9d651242a68801159763d09e9ec96e8a158dbf118dc090cd77a104c9"}, 277 | {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6119dc90483a5cb50a1306adb8d52c66e447da88ea44f323e0ae1a5fcb14256"}, 278 | {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:578114bc803a4c1ff9946d977c221e4376620a46cf78da267d946397dc9514a8"}, 279 | {file = "pydantic_core-2.16.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d8f99b147ff3fcf6b3cc60cb0c39ea443884d5559a30b1481e92495f2310ff2b"}, 280 | {file = "pydantic_core-2.16.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4ac6b4ce1e7283d715c4b729d8f9dab9627586dafce81d9eaa009dd7f25dd972"}, 281 | {file = "pydantic_core-2.16.3-cp39-none-win32.whl", hash = "sha256:e7774b570e61cb998490c5235740d475413a1f6de823169b4cf94e2fe9e9f6b2"}, 282 | {file = "pydantic_core-2.16.3-cp39-none-win_amd64.whl", hash = "sha256:9091632a25b8b87b9a605ec0e61f241c456e9248bfdcf7abdf344fdb169c81cf"}, 283 | {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:36fa178aacbc277bc6b62a2c3da95226520da4f4e9e206fdf076484363895d2c"}, 284 | {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:dcca5d2bf65c6fb591fff92da03f94cd4f315972f97c21975398bd4bd046854a"}, 285 | {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a72fb9963cba4cd5793854fd12f4cfee731e86df140f59ff52a49b3552db241"}, 286 | {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b60cc1a081f80a2105a59385b92d82278b15d80ebb3adb200542ae165cd7d183"}, 287 | {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cbcc558401de90a746d02ef330c528f2e668c83350f045833543cd57ecead1ad"}, 288 | {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:fee427241c2d9fb7192b658190f9f5fd6dfe41e02f3c1489d2ec1e6a5ab1e04a"}, 289 | {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f4cb85f693044e0f71f394ff76c98ddc1bc0953e48c061725e540396d5c8a2e1"}, 290 | {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b29eeb887aa931c2fcef5aa515d9d176d25006794610c264ddc114c053bf96fe"}, 291 | {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a425479ee40ff021f8216c9d07a6a3b54b31c8267c6e17aa88b70d7ebd0e5e5b"}, 292 | {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:5c5cbc703168d1b7a838668998308018a2718c2130595e8e190220238addc96f"}, 293 | {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99b6add4c0b39a513d323d3b93bc173dac663c27b99860dd5bf491b240d26137"}, 294 | {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f76ee558751746d6a38f89d60b6228fa174e5172d143886af0f85aa306fd89"}, 295 | {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00ee1c97b5364b84cb0bd82e9bbf645d5e2871fb8c58059d158412fee2d33d8a"}, 296 | {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:287073c66748f624be4cef893ef9174e3eb88fe0b8a78dc22e88eca4bc357ca6"}, 297 | {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ed25e1835c00a332cb10c683cd39da96a719ab1dfc08427d476bce41b92531fc"}, 298 | {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:86b3d0033580bd6bbe07590152007275bd7af95f98eaa5bd36f3da219dcd93da"}, 299 | {file = "pydantic_core-2.16.3.tar.gz", hash = "sha256:1cac689f80a3abab2d3c0048b29eea5751114054f032a941a32de4c852c59cad"}, 300 | ] 301 | 302 | [package.dependencies] 303 | typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" 304 | 305 | [[package]] 306 | name = "pygments" 307 | version = "2.17.2" 308 | description = "Pygments is a syntax highlighting package written in Python." 309 | optional = false 310 | python-versions = ">=3.7" 311 | files = [ 312 | {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"}, 313 | {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"}, 314 | ] 315 | 316 | [package.extras] 317 | plugins = ["importlib-metadata"] 318 | windows-terminal = ["colorama (>=0.4.6)"] 319 | 320 | [[package]] 321 | name = "pytest" 322 | version = "8.1.1" 323 | description = "pytest: simple powerful testing with Python" 324 | optional = false 325 | python-versions = ">=3.8" 326 | files = [ 327 | {file = "pytest-8.1.1-py3-none-any.whl", hash = "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7"}, 328 | {file = "pytest-8.1.1.tar.gz", hash = "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044"}, 329 | ] 330 | 331 | [package.dependencies] 332 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 333 | iniconfig = "*" 334 | packaging = "*" 335 | pluggy = ">=1.4,<2.0" 336 | 337 | [package.extras] 338 | testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 339 | 340 | [[package]] 341 | name = "rich" 342 | version = "13.7.1" 343 | description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" 344 | optional = false 345 | python-versions = ">=3.7.0" 346 | files = [ 347 | {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, 348 | {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, 349 | ] 350 | 351 | [package.dependencies] 352 | markdown-it-py = ">=2.2.0" 353 | pygments = ">=2.13.0,<3.0.0" 354 | 355 | [package.extras] 356 | jupyter = ["ipywidgets (>=7.5.1,<9)"] 357 | 358 | [[package]] 359 | name = "ruff" 360 | version = "0.3.5" 361 | description = "An extremely fast Python linter and code formatter, written in Rust." 362 | optional = false 363 | python-versions = ">=3.7" 364 | files = [ 365 | {file = "ruff-0.3.5-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:aef5bd3b89e657007e1be6b16553c8813b221ff6d92c7526b7e0227450981eac"}, 366 | {file = "ruff-0.3.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:89b1e92b3bd9fca249153a97d23f29bed3992cff414b222fcd361d763fc53f12"}, 367 | {file = "ruff-0.3.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e55771559c89272c3ebab23326dc23e7f813e492052391fe7950c1a5a139d89"}, 368 | {file = "ruff-0.3.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dabc62195bf54b8a7876add6e789caae0268f34582333cda340497c886111c39"}, 369 | {file = "ruff-0.3.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3a05f3793ba25f194f395578579c546ca5d83e0195f992edc32e5907d142bfa3"}, 370 | {file = "ruff-0.3.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:dfd3504e881082959b4160ab02f7a205f0fadc0a9619cc481982b6837b2fd4c0"}, 371 | {file = "ruff-0.3.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87258e0d4b04046cf1d6cc1c56fadbf7a880cc3de1f7294938e923234cf9e498"}, 372 | {file = "ruff-0.3.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:712e71283fc7d9f95047ed5f793bc019b0b0a29849b14664a60fd66c23b96da1"}, 373 | {file = "ruff-0.3.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a532a90b4a18d3f722c124c513ffb5e5eaff0cc4f6d3aa4bda38e691b8600c9f"}, 374 | {file = "ruff-0.3.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:122de171a147c76ada00f76df533b54676f6e321e61bd8656ae54be326c10296"}, 375 | {file = "ruff-0.3.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d80a6b18a6c3b6ed25b71b05eba183f37d9bc8b16ace9e3d700997f00b74660b"}, 376 | {file = "ruff-0.3.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a7b6e63194c68bca8e71f81de30cfa6f58ff70393cf45aab4c20f158227d5936"}, 377 | {file = "ruff-0.3.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a759d33a20c72f2dfa54dae6e85e1225b8e302e8ac655773aff22e542a300985"}, 378 | {file = "ruff-0.3.5-py3-none-win32.whl", hash = "sha256:9d8605aa990045517c911726d21293ef4baa64f87265896e491a05461cae078d"}, 379 | {file = "ruff-0.3.5-py3-none-win_amd64.whl", hash = "sha256:dc56bb16a63c1303bd47563c60482a1512721053d93231cf7e9e1c6954395a0e"}, 380 | {file = "ruff-0.3.5-py3-none-win_arm64.whl", hash = "sha256:faeeae9905446b975dcf6d4499dc93439b131f1443ee264055c5716dd947af55"}, 381 | {file = "ruff-0.3.5.tar.gz", hash = "sha256:a067daaeb1dc2baf9b82a32dae67d154d95212080c80435eb052d95da647763d"}, 382 | ] 383 | 384 | [[package]] 385 | name = "sniffio" 386 | version = "1.3.1" 387 | description = "Sniff out which async library your code is running under" 388 | optional = false 389 | python-versions = ">=3.7" 390 | files = [ 391 | {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, 392 | {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, 393 | ] 394 | 395 | [[package]] 396 | name = "typing-extensions" 397 | version = "4.10.0" 398 | description = "Backported and Experimental Type Hints for Python 3.8+" 399 | optional = false 400 | python-versions = ">=3.8" 401 | files = [ 402 | {file = "typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475"}, 403 | {file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"}, 404 | ] 405 | 406 | [metadata] 407 | lock-version = "2.0" 408 | python-versions = "^3.11" 409 | content-hash = "be52c3e5c00b6cb42c35ecf09bf11c15ed7f015319ff1f844c7908b924a025f9" 410 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "grafana_dashboard_manager" 3 | version = "0.2.10" 4 | description = "A cli utility that uses Grafana's HTTP API to easily save and restore dashboards." 5 | authors = ["Vince Chan "] 6 | license = "MIT" 7 | readme = "README.md" 8 | homepage = "https://www.beamconnectivity.com" 9 | repository = "https://github.com/Beam-Connectivity/grafana-dashboard-manager" 10 | keywords = ["grafana", "dashboard", "json"] 11 | 12 | [tool.poetry.dependencies] 13 | python = "^3.11" 14 | rich = "^13.7.1" 15 | httpx = "^0.27.0" 16 | pydantic = "^2.6.4" 17 | 18 | [tool.poetry.group.dev.dependencies] 19 | pytest = "^8.1.1" 20 | ruff = "^0.3.5" 21 | 22 | [build-system] 23 | requires = ["poetry-core>=1.0.0"] 24 | build-backend = "poetry.core.masonry.api" 25 | 26 | [tool.poetry.scripts] 27 | grafana-dashboard-manager = "grafana_dashboard_manager.__main__:app" 28 | 29 | [tool.ruff] 30 | line-length = 120 31 | ignore-init-module-imports = true 32 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | line-length = 120 2 | lint.ignore-init-module-imports = true 3 | lint.extend-select = ["E501", "F401"] 4 | lint.select = [ 5 | "I", # isort 6 | "D", # pydocstyle 7 | "UP", # pyupgrade 8 | "B", # flake8-bugbear 9 | ] 10 | lint.ignore = [ 11 | "D100", 12 | "D203", 13 | "D211", 14 | "D212", 15 | "D400", 16 | "D401", 17 | "D407", 18 | "D415", 19 | "D205", 20 | "B008", 21 | ] 22 | 23 | # https://docs.astral.sh/ruff/rules/ 24 | # D100 undocumented-public-module Missing docstring in public module 25 | # D203 one-blank-line-before-class 1 blank line required before class docstring 26 | # D205 blank-line-after-summary 1 blank line required between summary line and description 27 | # D211 blank-line-before-class No blank lines allowed before class docstring 28 | # D212 multi-line-summary-first-line Multi-line docstring summary should start at the first line 29 | # D400 ends-in-period First line should end with a period 30 | # D401 non-imperative-mood First line of docstring should be in imperative mood: "{first_line}" 31 | # D407 dashed-underline-after-section Missing dashed underline after section 32 | # D415 ends-in-period First line should end with a period, question mark, or exclamation point 33 | # B008 function-call-in-default-argument Do not perform function call {name} in argument defaults; instead, perform the call 34 | # within the function, or read the default from a module-level singleton variable 35 | 36 | [lint.per-file-ignores] 37 | "__init__.py" = ["D100", "D101", "D102", "D104", "D105", "F401"] 38 | "*/tests/*" = ["D100", "D101", "D102", "D103", "D104", "D105", "D107"] 39 | -------------------------------------------------------------------------------- /tests/integration/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | mysql: 5 | image: mysql 6 | environment: 7 | MYSQL_ROOT_PASSWORD: root_password 8 | MYSQL_DATABASE: grafana-db 9 | MYSQL_USER: username 10 | MYSQL_PASSWORD: password 11 | expose: 12 | - "3306" 13 | healthcheck: 14 | test: mysqladmin ping -h 127.0.0.1 -u $$MYSQL_USER --password=$$MYSQL_PASSWORD 15 | start_period: 2s 16 | interval: 1s 17 | timeout: 1s 18 | retries: 30 19 | 20 | grafana: 21 | image: grafana/grafana 22 | depends_on: 23 | mysql: 24 | condition: service_healthy 25 | environment: 26 | GF_SECURITY_ADMIN_PASSWORD: admin 27 | GF_DATABASE_TYPE: mysql 28 | GF_DATABASE_HOST: mysql 29 | GF_DATABASE_NAME: grafana-db 30 | GF_DATABASE_USER: username 31 | GF_DATABASE_PASSWORD: password 32 | ports: 33 | - "3000:3000" 34 | --------------------------------------------------------------------------------