From 40f721c4476fbfbddd89d9f3eeb14d4d52874da1 Mon Sep 17 00:00:00 2001 From: Joel Heaps Date: Thu, 28 Sep 2023 21:40:32 -0500 Subject: [PATCH 01/21] Switch to pyproject.toml --- .pdm-python | 1 + pdm.lock | 147 +++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 28 ++++++++++ 3 files changed, 176 insertions(+) create mode 100644 .pdm-python create mode 100644 pdm.lock create mode 100644 pyproject.toml diff --git a/.pdm-python b/.pdm-python new file mode 100644 index 0000000..49a44d4 --- /dev/null +++ b/.pdm-python @@ -0,0 +1 @@ +/Users/joel/Documents/projects 🚧/git/prometheus-qbittorrent-exporter/.venv/bin/python \ No newline at end of file diff --git a/pdm.lock b/pdm.lock new file mode 100644 index 0000000..4836d2b --- /dev/null +++ b/pdm.lock @@ -0,0 +1,147 @@ +# This file is @generated by PDM. +# It is not intended for manual editing. + +[metadata] +groups = ["default"] +cross_platform = true +static_urls = false +lock_version = "4.3" +content_hash = "sha256:8ffbce02a8573413e52d271a4161d851967353831a0ddb77d52987808831cc7c" + +[[package]] +name = "attrdict" +version = "2.0.1" +summary = "A dict with attribute-style access" +dependencies = [ + "six", +] +files = [ + {file = "attrdict-2.0.1-py2.py3-none-any.whl", hash = "sha256:9432e3498c74ff7e1b20b3d93b45d766b71cbffa90923496f82c4ae38b92be34"}, + {file = "attrdict-2.0.1.tar.gz", hash = "sha256:35c90698b55c683946091177177a9e9c0713a0860f0e049febd72649ccd77b70"}, +] + +[[package]] +name = "certifi" +version = "2023.7.22" +requires_python = ">=3.6" +summary = "Python package for providing Mozilla's CA Bundle." +files = [ + {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, + {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.2.0" +requires_python = ">=3.7.0" +summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +files = [ + {file = "charset-normalizer-3.2.0.tar.gz", hash = "sha256:3bb3d25a8e6c0aedd251753a79ae98a093c7e7b471faa3aa9a93a81431987ace"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4957669ef390f0e6719db3613ab3a7631e68424604a7b448f079bee145da6e09"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46fb8c61d794b78ec7134a715a3e564aafc8f6b5e338417cb19fe9f57a5a9bf2"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f25c229a6ba38a35ae6e25ca1264621cc25d4d38dca2942a7fce0b67a4efe918"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2efb1bd13885392adfda4614c33d3b68dee4921fd0ac1d3988f8cbb7d589e72a"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f30b48dd7fa1474554b0b0f3fdfdd4c13b5c737a3c6284d3cdc424ec0ffff3a"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:246de67b99b6851627d945db38147d1b209a899311b1305dd84916f2b88526c6"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bd9b3b31adcb054116447ea22caa61a285d92e94d710aa5ec97992ff5eb7cf3"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8c2f5e83493748286002f9369f3e6607c565a6a90425a3a1fef5ae32a36d749d"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3170c9399da12c9dc66366e9d14da8bf7147e1e9d9ea566067bbce7bb74bd9c2"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7a4826ad2bd6b07ca615c74ab91f32f6c96d08f6fcc3902ceeedaec8cdc3bcd6"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:3b1613dd5aee995ec6d4c69f00378bbd07614702a315a2cf6c1d21461fe17c23"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9e608aafdb55eb9f255034709e20d5a83b6d60c054df0802fa9c9883d0a937aa"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-win32.whl", hash = "sha256:f2a1d0fd4242bd8643ce6f98927cf9c04540af6efa92323e9d3124f57727bfc1"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:681eb3d7e02e3c3655d1b16059fbfb605ac464c834a0c629048a30fad2b27489"}, + {file = "charset_normalizer-3.2.0-py3-none-any.whl", hash = "sha256:8e098148dd37b4ce3baca71fb394c81dc5d9c7728c95df695d2dca218edf40e6"}, +] + +[[package]] +name = "idna" +version = "3.4" +requires_python = ">=3.5" +summary = "Internationalized Domain Names in Applications (IDNA)" +files = [ + {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, + {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, +] + +[[package]] +name = "prometheus-client" +version = "0.16.0" +requires_python = ">=3.6" +summary = "Python client for the Prometheus monitoring system." +files = [ + {file = "prometheus_client-0.16.0-py3-none-any.whl", hash = "sha256:0836af6eb2c8f4fed712b2f279f6c0a8bbab29f9f4aa15276b91c7cb0d1616ab"}, + {file = "prometheus_client-0.16.0.tar.gz", hash = "sha256:a03e35b359f14dd1630898543e2120addfdeacd1a6069c1367ae90fd93ad3f48"}, +] + +[[package]] +name = "python-json-logger" +version = "2.0.2" +requires_python = ">=3.5" +summary = "A python library adding a json log formatter" +files = [ + {file = "python-json-logger-2.0.2.tar.gz", hash = "sha256:202a4f29901a4b8002a6d1b958407eeb2dd1d83c18b18b816f5b64476dde9096"}, + {file = "python_json_logger-2.0.2-py3-none-any.whl", hash = "sha256:99310d148f054e858cd5f4258794ed6777e7ad2c3fd7e1c1b527f1cba4d08420"}, +] + +[[package]] +name = "qbittorrent-api" +version = "2023.4.47" +summary = "Python client for qBittorrent v4.1+ Web API." +dependencies = [ + "requests>=2.16.0", + "setuptools", + "six", + "urllib3>=1.24.2", +] +files = [ + {file = "qbittorrent-api-2023.4.47.tar.gz", hash = "sha256:7199daf5cee69aa8bc98829d0c4548ec21f682747a7368edc59b94b2f8f7dec0"}, + {file = "qbittorrent_api-2023.4.47-py2.py3-none-any.whl", hash = "sha256:71602d783ffa5ab64f6a70fccb5e259df6b289140c7abf1c6ec4458d0f41000e"}, +] + +[[package]] +name = "requests" +version = "2.31.0" +requires_python = ">=3.7" +summary = "Python HTTP for Humans." +dependencies = [ + "certifi>=2017.4.17", + "charset-normalizer<4,>=2", + "idna<4,>=2.5", + "urllib3<3,>=1.21.1", +] +files = [ + {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, + {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, +] + +[[package]] +name = "setuptools" +version = "68.2.2" +requires_python = ">=3.8" +summary = "Easily download, build, install, upgrade, and uninstall Python packages" +files = [ + {file = "setuptools-68.2.2-py3-none-any.whl", hash = "sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a"}, + {file = "setuptools-68.2.2.tar.gz", hash = "sha256:4ac1475276d2f1c48684874089fefcd83bd7162ddaafb81fac866ba0db282a87"}, +] + +[[package]] +name = "six" +version = "1.16.0" +requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +summary = "Python 2 and 3 compatibility utilities" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "urllib3" +version = "2.0.5" +requires_python = ">=3.7" +summary = "HTTP library with thread-safe connection pooling, file post, and more." +files = [ + {file = "urllib3-2.0.5-py3-none-any.whl", hash = "sha256:ef16afa8ba34a1f989db38e1dbbe0c302e4289a47856990d0682e374563ce35e"}, + {file = "urllib3-2.0.5.tar.gz", hash = "sha256:13abf37382ea2ce6fb744d4dad67838eec857c9f4f57009891805e0b5e123594"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..40c303e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "prometheus-qbittorrent-exporter" +version = "1.3.0" +description = "Prometheus exporter for qbittorrent" +authors = [ + {name = "Esteban Sanchez", email = "esteban.sanchez@gmail.com"}, +] +dependencies = [ + "attrdict==2.0.1", + "prometheus_client==0.16.0", + "python-json-logger==2.0.2", + "qbittorrent-api==2023.4.47", +] +requires-python = ">=3.11" +readme = "README.md" +keywords = ["prometheus", "qbittorrent"] +classifiers = [] + +[project.urls] +Homepage = "https://github.com/esanchezm/prometheus-qbittorrent-exporter" +Downloads = "https://github.com/esanchezm/prometheus-qbittorrent-exporter/archive/1.3.0.tar.gz" + +[project.scripts] +qbittorrent-exporter = "qbittorrent_exporter.exporter:main" + +[build-system] +requires = ["pdm-backend"] +build-backend = "pdm.backend" From f4a5266125357a890dcef626c66faf4bd5874f7c Mon Sep 17 00:00:00 2001 From: Joel Heaps Date: Thu, 28 Sep 2023 21:56:45 -0500 Subject: [PATCH 02/21] Bump dependency versions --- pyproject.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 40c303e..2278db1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,10 +6,10 @@ authors = [ {name = "Esteban Sanchez", email = "esteban.sanchez@gmail.com"}, ] dependencies = [ - "attrdict==2.0.1", - "prometheus_client==0.16.0", - "python-json-logger==2.0.2", - "qbittorrent-api==2023.4.47", + "attrdict>=2.0.1", + "prometheus-client>=0.17.1", + "python-json-logger>=2.0.7", + "qbittorrent-api>=2023.9.53", ] requires-python = ">=3.11" readme = "README.md" From 78cee60afdc848ca56d9351395fd7951224c08fa Mon Sep 17 00:00:00 2001 From: Joel Heaps Date: Thu, 28 Sep 2023 21:57:22 -0500 Subject: [PATCH 03/21] Ignore config.env temporarily --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index b6e4761..75b8fc6 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ dmypy.json # Pyre type checker .pyre/ + +# Temporary ignore +config.env \ No newline at end of file From 1fc4383a95ebda6376c8d7f755291a8a5467622a Mon Sep 17 00:00:00 2001 From: Joel Heaps Date: Thu, 28 Sep 2023 21:57:48 -0500 Subject: [PATCH 04/21] Bump dependency versions --- pdm.lock | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/pdm.lock b/pdm.lock index 4836d2b..77b8935 100644 --- a/pdm.lock +++ b/pdm.lock @@ -6,7 +6,7 @@ groups = ["default"] cross_platform = true static_urls = false lock_version = "4.3" -content_hash = "sha256:8ffbce02a8573413e52d271a4161d851967353831a0ddb77d52987808831cc7c" +content_hash = "sha256:619785556320f35b6aca0bf164725dc3afa5a1a978782d66a3add0b9e1ad8738" [[package]] name = "attrdict" @@ -65,39 +65,49 @@ files = [ {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, ] +[[package]] +name = "packaging" +version = "23.1" +requires_python = ">=3.7" +summary = "Core utilities for Python packages" +files = [ + {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, + {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, +] + [[package]] name = "prometheus-client" -version = "0.16.0" +version = "0.17.1" requires_python = ">=3.6" summary = "Python client for the Prometheus monitoring system." files = [ - {file = "prometheus_client-0.16.0-py3-none-any.whl", hash = "sha256:0836af6eb2c8f4fed712b2f279f6c0a8bbab29f9f4aa15276b91c7cb0d1616ab"}, - {file = "prometheus_client-0.16.0.tar.gz", hash = "sha256:a03e35b359f14dd1630898543e2120addfdeacd1a6069c1367ae90fd93ad3f48"}, + {file = "prometheus_client-0.17.1-py3-none-any.whl", hash = "sha256:e537f37160f6807b8202a6fc4764cdd19bac5480ddd3e0d463c3002b34462101"}, + {file = "prometheus_client-0.17.1.tar.gz", hash = "sha256:21e674f39831ae3f8acde238afd9a27a37d0d2fb5a28ea094f0ce25d2cbf2091"}, ] [[package]] name = "python-json-logger" -version = "2.0.2" -requires_python = ">=3.5" +version = "2.0.7" +requires_python = ">=3.6" summary = "A python library adding a json log formatter" files = [ - {file = "python-json-logger-2.0.2.tar.gz", hash = "sha256:202a4f29901a4b8002a6d1b958407eeb2dd1d83c18b18b816f5b64476dde9096"}, - {file = "python_json_logger-2.0.2-py3-none-any.whl", hash = "sha256:99310d148f054e858cd5f4258794ed6777e7ad2c3fd7e1c1b527f1cba4d08420"}, + {file = "python-json-logger-2.0.7.tar.gz", hash = "sha256:23e7ec02d34237c5aa1e29a070193a4ea87583bb4e7f8fd06d3de8264c4b2e1c"}, + {file = "python_json_logger-2.0.7-py3-none-any.whl", hash = "sha256:f380b826a991ebbe3de4d897aeec42760035ac760345e57b812938dc8b35e2bd"}, ] [[package]] name = "qbittorrent-api" -version = "2023.4.47" +version = "2023.9.53" summary = "Python client for qBittorrent v4.1+ Web API." dependencies = [ + "packaging", "requests>=2.16.0", - "setuptools", "six", "urllib3>=1.24.2", ] files = [ - {file = "qbittorrent-api-2023.4.47.tar.gz", hash = "sha256:7199daf5cee69aa8bc98829d0c4548ec21f682747a7368edc59b94b2f8f7dec0"}, - {file = "qbittorrent_api-2023.4.47-py2.py3-none-any.whl", hash = "sha256:71602d783ffa5ab64f6a70fccb5e259df6b289140c7abf1c6ec4458d0f41000e"}, + {file = "qbittorrent-api-2023.9.53.tar.gz", hash = "sha256:fead1b2f55b1227ea088ea7d90b5022d94694bfd9dd9176beb5ad1c195d044ff"}, + {file = "qbittorrent_api-2023.9.53-py2.py3-none-any.whl", hash = "sha256:963ae59d16a9c4a9aa1714fb7f6799539dc2693136cdc0e377daab3612ca775a"}, ] [[package]] @@ -116,16 +126,6 @@ files = [ {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, ] -[[package]] -name = "setuptools" -version = "68.2.2" -requires_python = ">=3.8" -summary = "Easily download, build, install, upgrade, and uninstall Python packages" -files = [ - {file = "setuptools-68.2.2-py3-none-any.whl", hash = "sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a"}, - {file = "setuptools-68.2.2.tar.gz", hash = "sha256:4ac1475276d2f1c48684874089fefcd83bd7162ddaafb81fac866ba0db282a87"}, -] - [[package]] name = "six" version = "1.16.0" From 31f309c942ebf76c4daf2a13d3e6f65100f52d3a Mon Sep 17 00:00:00 2001 From: Joel Heaps Date: Thu, 28 Sep 2023 22:07:25 -0500 Subject: [PATCH 05/21] Switch to attridict, format with Black --- pdm.lock | 15 +++---- pyproject.toml | 2 +- qbittorrent_exporter/exporter.py | 73 ++++++++++++++++++++------------ 3 files changed, 52 insertions(+), 38 deletions(-) diff --git a/pdm.lock b/pdm.lock index 77b8935..a2be35f 100644 --- a/pdm.lock +++ b/pdm.lock @@ -6,18 +6,15 @@ groups = ["default"] cross_platform = true static_urls = false lock_version = "4.3" -content_hash = "sha256:619785556320f35b6aca0bf164725dc3afa5a1a978782d66a3add0b9e1ad8738" +content_hash = "sha256:637d322b802d007082b71cf7d27382f3b6b5618434faf6b346358abe81fc2f7f" [[package]] -name = "attrdict" -version = "2.0.1" -summary = "A dict with attribute-style access" -dependencies = [ - "six", -] +name = "attridict" +version = "0.0.8" +summary = "A dict implementation with support for easy and clean access of its values through attributes" files = [ - {file = "attrdict-2.0.1-py2.py3-none-any.whl", hash = "sha256:9432e3498c74ff7e1b20b3d93b45d766b71cbffa90923496f82c4ae38b92be34"}, - {file = "attrdict-2.0.1.tar.gz", hash = "sha256:35c90698b55c683946091177177a9e9c0713a0860f0e049febd72649ccd77b70"}, + {file = "attridict-0.0.8-py3-none-any.whl", hash = "sha256:8ee65af81f7762354e4514c443bbc04786a924c8e3e610c7883d2efbf323df6d"}, + {file = "attridict-0.0.8.tar.gz", hash = "sha256:23a17671b9439d36e2bdb0a69c09f033abab0900a9df178e0f89aa1b2c42c5cd"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 2278db1..0c3e925 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,10 +6,10 @@ authors = [ {name = "Esteban Sanchez", email = "esteban.sanchez@gmail.com"}, ] dependencies = [ - "attrdict>=2.0.1", "prometheus-client>=0.17.1", "python-json-logger>=2.0.7", "qbittorrent-api>=2023.9.53", + "attridict>=0.0.8", ] requires-python = ">=3.11" readme = "README.md" diff --git a/qbittorrent_exporter/exporter.py b/qbittorrent_exporter/exporter.py index 5e69547..a9b6111 100644 --- a/qbittorrent_exporter/exporter.py +++ b/qbittorrent_exporter/exporter.py @@ -3,9 +3,8 @@ import sys import signal import faulthandler -from attrdict import AttrDict +import attridict from qbittorrentapi import Client, TorrentStates -from qbittorrentapi.exceptions import APIConnectionError from prometheus_client import start_http_server from prometheus_client.core import GaugeMetricFamily, CounterMetricFamily, REGISTRY import logging @@ -17,7 +16,7 @@ logger = logging.getLogger() -class QbittorrentMetricsCollector(): +class QbittorrentMetricsCollector: TORRENT_STATUSES = [ "checking", "complete", @@ -34,7 +33,7 @@ def __init__(self, config): port=config["port"], username=config["username"], password=config["password"], - VERIFY_WEBUI_CERTIFICATE=config["verify_webui_certificate"] + VERIFY_WEBUI_CERTIFICATE=config["verify_webui_certificate"], ) def collect(self): @@ -98,13 +97,13 @@ def get_qbittorrent_status_metrics(self): "name": f"{self.config['metrics_prefix']}_dl_info_data", "value": response.get("dl_info_data", 0), "help": "Data downloaded this session (bytes)", - "type": "counter" + "type": "counter", }, { "name": f"{self.config['metrics_prefix']}_up_info_data", "value": response.get("up_info_data", 0), "help": "Data uploaded this session (bytes)", - "type": "counter" + "type": "counter", }, ] @@ -117,29 +116,43 @@ def get_qbittorrent_torrent_tags_metrics(self): return [] metrics = [] - categories.Uncategorized = AttrDict({'name': 'Uncategorized', 'savePath': ''}) + categories.Uncategorized = attridict({"name": "Uncategorized", "savePath": ""}) for category in categories: - category_torrents = [t for t in torrents if t['category'] == category or (category == "Uncategorized" and t['category'] == "")] + category_torrents = [ + t + for t in torrents + if t["category"] == category + or (category == "Uncategorized" and t["category"] == "") + ] for status in self.TORRENT_STATUSES: status_prop = f"is_{status}" status_torrents = [ - t for t in category_torrents if getattr(TorrentStates, status_prop).fget(TorrentStates(t['state'])) + t + for t in category_torrents + if getattr(TorrentStates, status_prop).fget( + TorrentStates(t["state"]) + ) ] - metrics.append({ - "name": f"{self.config['metrics_prefix']}_torrents_count", - "value": len(status_torrents), - "labels": { - "status": status, - "category": category, - }, - "help": f"Number of torrents in status {status} under category {category}" - }) + metrics.append( + { + "name": f"{self.config['metrics_prefix']}_torrents_count", + "value": len(status_torrents), + "labels": { + "status": status, + "category": category, + }, + "help": ( + f"Number of torrents in status {status} under category" + f" {category}" + ), + } + ) return metrics -class SignalHandler(): +class SignalHandler: def __init__(self): self.shutdownCount = 0 @@ -157,6 +170,7 @@ def _on_signal_received(self, signal, frame): logger.info("Exporter is shutting down") self.shutdownCount += 1 + def get_config_value(key, default=""): input_path = os.environ.get("FILE__" + key, None) if input_path is not None: @@ -173,12 +187,11 @@ def main(): # Init logger so it can be used logHandler = logging.StreamHandler() formatter = jsonlogger.JsonFormatter( - "%(asctime) %(levelname) %(message)", - datefmt="%Y-%m-%d %H:%M:%S" + "%(asctime) %(levelname) %(message)", datefmt="%Y-%m-%d %H:%M:%S" ) logHandler.setFormatter(formatter) logger.addHandler(logHandler) - logger.setLevel("INFO") # default until config is loaded + logger.setLevel("INFO") # default until config is loaded config = { "host": get_config_value("QBITTORRENT_HOST", ""), @@ -188,7 +201,9 @@ def main(): "exporter_port": int(get_config_value("EXPORTER_PORT", "8000")), "log_level": get_config_value("EXPORTER_LOG_LEVEL", "INFO"), "metrics_prefix": get_config_value("METRICS_PREFIX", "qbittorrent"), - "verify_webui_certificate": get_config_value("VERIFY_WEBUI_CERTIFICATE", "True") == "True", + "verify_webui_certificate": ( + get_config_value("VERIFY_WEBUI_CERTIFICATE", "True") == "True" + ), } # set level once config has been loaded logger.setLevel(config["log_level"]) @@ -197,10 +212,14 @@ def main(): signal_handler = SignalHandler() if not config["host"]: - logger.error("No host specified, please set QBITTORRENT_HOST environment variable") + logger.error( + "No host specified, please set QBITTORRENT_HOST environment variable" + ) sys.exit(1) if not config["port"]: - logger.error("No port specified, please set QBITTORRENT_PORT environment variable") + logger.error( + "No port specified, please set QBITTORRENT_PORT environment variable" + ) sys.exit(1) # Register our custom collector @@ -209,9 +228,7 @@ def main(): # Start server start_http_server(config["exporter_port"]) - logger.info( - f"Exporter listening on port {config['exporter_port']}" - ) + logger.info(f"Exporter listening on port {config['exporter_port']}") while not signal_handler.is_shutting_down(): time.sleep(1) From de1d0fa7d5f227f659a049218fd178486c9c221f Mon Sep 17 00:00:00 2001 From: Joel Heaps Date: Thu, 28 Sep 2023 22:09:14 -0500 Subject: [PATCH 06/21] Add main block for easier debugging --- qbittorrent_exporter/exporter.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/qbittorrent_exporter/exporter.py b/qbittorrent_exporter/exporter.py index a9b6111..e51e23c 100644 --- a/qbittorrent_exporter/exporter.py +++ b/qbittorrent_exporter/exporter.py @@ -234,3 +234,7 @@ def main(): time.sleep(1) logger.info("Exporter has shutdown") + + +if __name__ == "__main__": + main() From e8c01413653b41d7df87413d8403e6ae1b2f58c4 Mon Sep 17 00:00:00 2001 From: Joel Heaps Date: Thu, 28 Sep 2023 22:14:14 -0500 Subject: [PATCH 07/21] Minor grammar fixes in help notes --- qbittorrent_exporter/exporter.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/qbittorrent_exporter/exporter.py b/qbittorrent_exporter/exporter.py index e51e23c..a7e8647 100644 --- a/qbittorrent_exporter/exporter.py +++ b/qbittorrent_exporter/exporter.py @@ -76,22 +76,22 @@ def get_qbittorrent_status_metrics(self): "name": f"{self.config['metrics_prefix']}_up", "value": bool(response), "labels": {"version": version}, - "help": "Whether if server is alive or not", + "help": "Whether server is reachable", }, { "name": f"{self.config['metrics_prefix']}_connected", "value": response.get("connection_status", "") == "connected", - "help": "Whether if server is connected or not", + "help": "Whether server is currently connected", }, { "name": f"{self.config['metrics_prefix']}_firewalled", "value": response.get("connection_status", "") == "firewalled", - "help": "Whether if server is under a firewall or not", + "help": "Whether if server is behind a firewall", }, { "name": f"{self.config['metrics_prefix']}_dht_nodes", "value": response.get("dht_nodes", 0), - "help": "DHT nodes connected to", + "help": "Number of connected DHT nodes", }, { "name": f"{self.config['metrics_prefix']}_dl_info_data", From 2f07f00592e944d959284816a4b75f2fdd67a75a Mon Sep 17 00:00:00 2001 From: Joel Heaps Date: Thu, 28 Sep 2023 22:24:49 -0500 Subject: [PATCH 08/21] Use enum for possible torrent statuses --- qbittorrent_exporter/exporter.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/qbittorrent_exporter/exporter.py b/qbittorrent_exporter/exporter.py index a7e8647..ae627af 100644 --- a/qbittorrent_exporter/exporter.py +++ b/qbittorrent_exporter/exporter.py @@ -9,6 +9,7 @@ from prometheus_client.core import GaugeMetricFamily, CounterMetricFamily, REGISTRY import logging from pythonjsonlogger import jsonlogger +from enum import StrEnum, auto # Enable dumps on stderr in case of segfault @@ -16,16 +17,15 @@ logger = logging.getLogger() -class QbittorrentMetricsCollector: - TORRENT_STATUSES = [ - "checking", - "complete", - "downloading", - "errored", - "paused", - "uploading", - ] +class TorrentStatus(StrEnum): + CHECKING = auto() + COMPLETE = auto() + ERRORED = auto() + PAUSED = auto() + UPLOADING = auto() + +class QbittorrentMetricsCollector: def __init__(self, config): self.config = config self.client = Client( @@ -125,8 +125,8 @@ def get_qbittorrent_torrent_tags_metrics(self): or (category == "Uncategorized" and t["category"] == "") ] - for status in self.TORRENT_STATUSES: - status_prop = f"is_{status}" + for status in TorrentStatus: + status_prop = f"is_{status.value}" status_torrents = [ t for t in category_torrents @@ -139,12 +139,12 @@ def get_qbittorrent_torrent_tags_metrics(self): "name": f"{self.config['metrics_prefix']}_torrents_count", "value": len(status_torrents), "labels": { - "status": status, + "status": status.value, "category": category, }, "help": ( - f"Number of torrents in status {status} under category" - f" {category}" + f"Number of torrents in status {status.value} under" + f" category {category}" ), } ) From 45e8381d700313190116bd336b53161c254f0db4 Mon Sep 17 00:00:00 2001 From: Joel Heaps Date: Thu, 28 Sep 2023 22:25:22 -0500 Subject: [PATCH 09/21] Minor grammar fixup --- qbittorrent_exporter/exporter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qbittorrent_exporter/exporter.py b/qbittorrent_exporter/exporter.py index ae627af..9c36d41 100644 --- a/qbittorrent_exporter/exporter.py +++ b/qbittorrent_exporter/exporter.py @@ -86,7 +86,7 @@ def get_qbittorrent_status_metrics(self): { "name": f"{self.config['metrics_prefix']}_firewalled", "value": response.get("connection_status", "") == "firewalled", - "help": "Whether if server is behind a firewall", + "help": "Whether server is behind a firewall", }, { "name": f"{self.config['metrics_prefix']}_dht_nodes", From 50ca1e4162daef1215591e572ebdefffd1e4d41f Mon Sep 17 00:00:00 2001 From: Joel Heaps Date: Thu, 28 Sep 2023 23:10:21 -0500 Subject: [PATCH 10/21] Refactor - add enums, dataclasses, more type hints --- qbittorrent_exporter/exporter.py | 142 +++++++++++++++++-------------- 1 file changed, 80 insertions(+), 62 deletions(-) diff --git a/qbittorrent_exporter/exporter.py b/qbittorrent_exporter/exporter.py index 9c36d41..72eb233 100644 --- a/qbittorrent_exporter/exporter.py +++ b/qbittorrent_exporter/exporter.py @@ -10,7 +10,8 @@ import logging from pythonjsonlogger import jsonlogger from enum import StrEnum, auto - +from typing import Iterable, Any +from dataclasses import dataclass, field # Enable dumps on stderr in case of segfault faulthandler.enable() @@ -25,8 +26,22 @@ class TorrentStatus(StrEnum): UPLOADING = auto() +class MetricType(StrEnum): + GAUGE = auto() + COUNTER = auto() + + +@dataclass +class Metric: + name: str + value: int | float | bool + labels: dict[str, str] = field(default_factory={}) + help_text: str = "" + metric_type: MetricType = MetricType.GAUGE + + class QbittorrentMetricsCollector: - def __init__(self, config): + def __init__(self, config: dict[str, str | int | bool]) -> None: self.config = config self.client = Client( host=config["host"], @@ -36,33 +51,31 @@ def __init__(self, config): VERIFY_WEBUI_CERTIFICATE=config["verify_webui_certificate"], ) - def collect(self): - metrics = self.get_qbittorrent_metrics() + def collect(self) -> Iterable[GaugeMetricFamily | CounterMetricFamily]: + metrics: list[Metric] = self.get_qbittorrent_metrics() for metric in metrics: - name = metric["name"] - value = metric["value"] - help_text = metric.get("help", "") - labels = metric.get("labels", {}) - metric_type = metric.get("type", "gauge") - - if metric_type == "counter": - prom_metric = CounterMetricFamily(name, help_text, labels=labels.keys()) + if metric.metric_type == MetricType.COUNTER: + prom_metric = CounterMetricFamily( + metric.name, metric.help_text, labels=metric.labels.keys() + ) else: - prom_metric = GaugeMetricFamily(name, help_text, labels=labels.keys()) - prom_metric.add_metric(value=value, labels=labels.values()) + prom_metric = GaugeMetricFamily( + metric.name, metric.help_text, labels=metric.labels.keys() + ) + prom_metric.add_metric(value=metric.value, labels=metric.labels.values()) yield prom_metric - def get_qbittorrent_metrics(self): - metrics = [] - metrics.extend(self.get_qbittorrent_status_metrics()) - metrics.extend(self.get_qbittorrent_torrent_tags_metrics()) + def get_qbittorrent_metrics(self) -> list[Metric]: + metrics: list[Metric] = [] + metrics.extend(self._get_qbittorrent_status_metrics()) + metrics.extend(self._get_qbittorrent_torrent_tags_metrics()) return metrics - def get_qbittorrent_status_metrics(self): - response = {} - version = "" + def _get_qbittorrent_status_metrics(self) -> list[dict]: + response: dict[str, Any] = {} + version: str = "" # Fetch data from API try: @@ -72,42 +85,47 @@ def get_qbittorrent_status_metrics(self): logger.error(f"Couldn't get server info: {e}") return [ - { - "name": f"{self.config['metrics_prefix']}_up", - "value": bool(response), - "labels": {"version": version}, - "help": "Whether server is reachable", - }, - { - "name": f"{self.config['metrics_prefix']}_connected", - "value": response.get("connection_status", "") == "connected", - "help": "Whether server is currently connected", - }, - { - "name": f"{self.config['metrics_prefix']}_firewalled", - "value": response.get("connection_status", "") == "firewalled", - "help": "Whether server is behind a firewall", - }, - { - "name": f"{self.config['metrics_prefix']}_dht_nodes", - "value": response.get("dht_nodes", 0), - "help": "Number of connected DHT nodes", - }, - { - "name": f"{self.config['metrics_prefix']}_dl_info_data", - "value": response.get("dl_info_data", 0), - "help": "Data downloaded this session (bytes)", - "type": "counter", - }, - { - "name": f"{self.config['metrics_prefix']}_up_info_data", - "value": response.get("up_info_data", 0), - "help": "Data uploaded this session (bytes)", - "type": "counter", - }, + Metric( + name=f"{self.config['metrics_prefix']}_up", + value=bool(response), + labels={"version": version}, + help_text="Whether server is reachable", + ), + Metric( + name=f"{self.config['metrics_prefix']}_connected", + value=response.get("connection_status", "") == "connected", + labels={}, # no labels in the example + help_text="Whether server is currently connected", + ), + Metric( + name=f"{self.config['metrics_prefix']}_firewalled", + value=response.get("connection_status", "") == "firewalled", + labels={}, # no labels in the example + help_text="Whether server is behind a firewall", + ), + Metric( + name=f"{self.config['metrics_prefix']}_dht_nodes", + value=response.get("dht_nodes", 0), + labels={}, # no labels in the example + help_text="Number of connected DHT nodes", + ), + Metric( + name=f"{self.config['metrics_prefix']}_dl_info_data", + value=response.get("dl_info_data", 0), + labels={}, # no labels in the example + help_text="Data downloaded this session (bytes)", + metric_type=MetricType.COUNTER, + ), + Metric( + name=f"{self.config['metrics_prefix']}_up_info_data", + value=response.get("up_info_data", 0), + labels={}, # no labels in the example + help_text="Data uploaded this session (bytes)", + metric_type=MetricType.COUNTER, + ), ] - def get_qbittorrent_torrent_tags_metrics(self): + def _get_qbittorrent_torrent_tags_metrics(self) -> list[Metric]: try: categories = self.client.torrent_categories.categories torrents = self.client.torrents.info() @@ -115,7 +133,7 @@ def get_qbittorrent_torrent_tags_metrics(self): logger.error(f"Couldn't fetch torrent info: {e}") return [] - metrics = [] + metrics: list[Metric] = [] categories.Uncategorized = attridict({"name": "Uncategorized", "savePath": ""}) for category in categories: category_torrents = [ @@ -135,18 +153,18 @@ def get_qbittorrent_torrent_tags_metrics(self): ) ] metrics.append( - { - "name": f"{self.config['metrics_prefix']}_torrents_count", - "value": len(status_torrents), - "labels": { + Metric( + name=f"{self.config['metrics_prefix']}_torrents_count", + value=len(status_torrents), + labels={ "status": status.value, "category": category, }, - "help": ( + help_text=( f"Number of torrents in status {status.value} under" f" category {category}" ), - } + ) ) return metrics From cd3911f53e2b19d207e14b52f315155e14986271 Mon Sep 17 00:00:00 2001 From: Joel Heaps Date: Fri, 29 Sep 2023 08:01:45 -0500 Subject: [PATCH 11/21] Bump Python container version --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index b094436..9b21d15 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.8-alpine3.17 +FROM python:3.11-alpine # Install package WORKDIR /code From e56a7133554ba74f894762c8571f596136a28a42 Mon Sep 17 00:00:00 2001 From: Joel Heaps Date: Fri, 29 Sep 2023 08:02:21 -0500 Subject: [PATCH 12/21] Remove legacy setup files --- setup.cfg | 2 -- setup.py | 26 -------------------------- 2 files changed, 28 deletions(-) delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index b88034e..0000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[metadata] -description-file = README.md diff --git a/setup.py b/setup.py deleted file mode 100644 index 4512a87..0000000 --- a/setup.py +++ /dev/null @@ -1,26 +0,0 @@ -from setuptools import setup - -with open("README.md", "r") as fh: - long_description = fh.read() - -setup( - name='prometheus-qbittorrent-exporter', - packages=['qbittorrent_exporter'], - version='1.3.0', - long_description=long_description, - long_description_content_type="text/markdown", - description='Prometheus exporter for qbittorrent', - author='Esteban Sanchez', - author_email='esteban.sanchez@gmail.com', - url='https://github.com/esanchezm/prometheus-qbittorrent-exporter', - download_url='https://github.com/esanchezm/prometheus-qbittorrent-exporter/archive/1.3.0.tar.gz', - keywords=['prometheus', 'qbittorrent'], - classifiers=[], - python_requires='>=3,<3.10', - install_requires=['attrdict==2.0.1', 'qbittorrent-api==2023.4.47', 'prometheus_client==0.16.0', 'python-json-logger==2.0.2'], - entry_points={ - 'console_scripts': [ - 'qbittorrent-exporter=qbittorrent_exporter.exporter:main', - ] - } -) From 3061eb1638cbca219a8586e73074e2cee6f69abf Mon Sep 17 00:00:00 2001 From: Joel Heaps Date: Fri, 29 Sep 2023 08:13:12 -0500 Subject: [PATCH 13/21] Match helptext to README text --- README.md | 12 ++++++------ qbittorrent_exporter/exporter.py | 25 +++++++++++++++++++------ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f1210d1..97d2a1e 100644 --- a/README.md +++ b/README.md @@ -53,12 +53,12 @@ These are the metrics this program exports, assuming the `METRICS_PREFIX` is `qb | Metric name | Type | Description | | --------------------------------------------------- | -------- | ---------------- | -| `qbittorrent_up` | gauge | Whether if the qBittorrent server is answering requests from this exporter. A `version` label with the server version is added | -| `qbittorrent_connected` | gauge | Whether if the qBittorrent server is connected to the Bittorrent network. | -| `qbittorrent_firewalled` | gauge | Whether if the qBittorrent server is connected to the Bittorrent network but is behind a firewall. | -| `qbittorrent_dht_nodes` | gauge | Number of DHT nodes connected to | -| `qbittorrent_dl_info_data` | counter | Data downloaded since the server started, in bytes | -| `qbittorrent_up_info_data` | counter | Data uploaded since the server started, in bytes | +| `qbittorrent_up` | gauge | Whether the qBittorrent server is answering requests from this exporter. A `version` label with the server version is added. | +| `qbittorrent_connected` | gauge | Whether the qBittorrent server is connected to the Bittorrent network. | +| `qbittorrent_firewalled` | gauge | Whether the qBittorrent server is connected to the Bittorrent network but is behind a firewall. | +| `qbittorrent_dht_nodes` | gauge | Number of DHT nodes connected to. | +| `qbittorrent_dl_info_data` | counter | Data downloaded since the server started, in bytes. | +| `qbittorrent_up_info_data` | counter | Data uploaded since the server started, in bytes. | | `qbittorrent_torrents_count` | gauge | Number of torrents for each `category` and `status`. Example: `qbittorrent_torrents_count{category="movies",status="downloading"}`| ## Screenshot diff --git a/qbittorrent_exporter/exporter.py b/qbittorrent_exporter/exporter.py index 72eb233..7e0f085 100644 --- a/qbittorrent_exporter/exporter.py +++ b/qbittorrent_exporter/exporter.py @@ -89,43 +89,56 @@ def _get_qbittorrent_status_metrics(self) -> list[dict]: name=f"{self.config['metrics_prefix']}_up", value=bool(response), labels={"version": version}, - help_text="Whether server is reachable", + help_text=( + "Whether the qBittorrent server is answering requests from this" + " exporter. A `version` label with the server version is added." + ), ), Metric( name=f"{self.config['metrics_prefix']}_connected", value=response.get("connection_status", "") == "connected", labels={}, # no labels in the example - help_text="Whether server is currently connected", + help_text=( + "Whether the qBittorrent server is connected to the Bittorrent" + " network." + ), ), Metric( name=f"{self.config['metrics_prefix']}_firewalled", value=response.get("connection_status", "") == "firewalled", labels={}, # no labels in the example - help_text="Whether server is behind a firewall", + help_text=( + "Whether the qBittorrent server is connected to the Bittorrent" + " network but is behind a firewall." + ), ), Metric( name=f"{self.config['metrics_prefix']}_dht_nodes", value=response.get("dht_nodes", 0), labels={}, # no labels in the example - help_text="Number of connected DHT nodes", + help_text="Number of DHT nodes connected to.", ), Metric( name=f"{self.config['metrics_prefix']}_dl_info_data", value=response.get("dl_info_data", 0), labels={}, # no labels in the example - help_text="Data downloaded this session (bytes)", + help_text="Data downloaded since the server started, in bytes.", metric_type=MetricType.COUNTER, ), Metric( name=f"{self.config['metrics_prefix']}_up_info_data", value=response.get("up_info_data", 0), labels={}, # no labels in the example - help_text="Data uploaded this session (bytes)", + help_text="Data uploaded since the server started, in bytes.", metric_type=MetricType.COUNTER, ), ] def _get_qbittorrent_torrent_tags_metrics(self) -> list[Metric]: + """ + Returns Metric object containing number of torrents for each `category` and + `status`. + """ try: categories = self.client.torrent_categories.categories torrents = self.client.torrents.info() From 0ac406c476ad078d88dd08666723a7eca0722955 Mon Sep 17 00:00:00 2001 From: Joel Heaps Date: Fri, 29 Sep 2023 08:13:23 -0500 Subject: [PATCH 14/21] Indicate license in pyproject.toml --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 0c3e925..7dd0006 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ requires-python = ">=3.11" readme = "README.md" keywords = ["prometheus", "qbittorrent"] +license = "GPL-3.0" classifiers = [] [project.urls] From ea4bdecdcf6b1b1d3e17b7a04e7c710bf926db5c Mon Sep 17 00:00:00 2001 From: Joel Heaps Date: Fri, 29 Sep 2023 09:46:51 -0500 Subject: [PATCH 15/21] Remove unneeded attridict dependency, add comments --- pdm.lock | 11 +---- pyproject.toml | 3 +- qbittorrent_exporter/exporter.py | 69 ++++++++++++++++++++++---------- 3 files changed, 49 insertions(+), 34 deletions(-) diff --git a/pdm.lock b/pdm.lock index a2be35f..c97ef6b 100644 --- a/pdm.lock +++ b/pdm.lock @@ -6,16 +6,7 @@ groups = ["default"] cross_platform = true static_urls = false lock_version = "4.3" -content_hash = "sha256:637d322b802d007082b71cf7d27382f3b6b5618434faf6b346358abe81fc2f7f" - -[[package]] -name = "attridict" -version = "0.0.8" -summary = "A dict implementation with support for easy and clean access of its values through attributes" -files = [ - {file = "attridict-0.0.8-py3-none-any.whl", hash = "sha256:8ee65af81f7762354e4514c443bbc04786a924c8e3e610c7883d2efbf323df6d"}, - {file = "attridict-0.0.8.tar.gz", hash = "sha256:23a17671b9439d36e2bdb0a69c09f033abab0900a9df178e0f89aa1b2c42c5cd"}, -] +content_hash = "sha256:ec00e4f386c7e3cac870ab101184e565ee290c82a8b3d759fb7ad2762d0366ba" [[package]] name = "certifi" diff --git a/pyproject.toml b/pyproject.toml index 7dd0006..3e22cf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,12 +9,11 @@ dependencies = [ "prometheus-client>=0.17.1", "python-json-logger>=2.0.7", "qbittorrent-api>=2023.9.53", - "attridict>=0.0.8", ] requires-python = ">=3.11" readme = "README.md" keywords = ["prometheus", "qbittorrent"] -license = "GPL-3.0" +license = {text = "GPL-3.0"} classifiers = [] [project.urls] diff --git a/qbittorrent_exporter/exporter.py b/qbittorrent_exporter/exporter.py index 7e0f085..4d59637 100644 --- a/qbittorrent_exporter/exporter.py +++ b/qbittorrent_exporter/exporter.py @@ -3,7 +3,6 @@ import sys import signal import faulthandler -import attridict from qbittorrentapi import Client, TorrentStates from prometheus_client import start_http_server from prometheus_client.core import GaugeMetricFamily, CounterMetricFamily, REGISTRY @@ -18,7 +17,11 @@ logger = logging.getLogger() -class TorrentStatus(StrEnum): +class TorrentStatuses(StrEnum): + """ + Represents possible torrent states. + """ + CHECKING = auto() COMPLETE = auto() ERRORED = auto() @@ -27,21 +30,29 @@ class TorrentStatus(StrEnum): class MetricType(StrEnum): + """ + Represents possible metric types (used in this project). + """ + GAUGE = auto() COUNTER = auto() @dataclass class Metric: + """ + Contains data and metadata about a single counter or gauge. + """ + name: str - value: int | float | bool - labels: dict[str, str] = field(default_factory={}) + value: Any + labels: dict[str, str] = field(default_factory=lambda: {}) # Default to empty dict help_text: str = "" metric_type: MetricType = MetricType.GAUGE class QbittorrentMetricsCollector: - def __init__(self, config: dict[str, str | int | bool]) -> None: + def __init__(self, config: dict) -> None: self.config = config self.client = Client( host=config["host"], @@ -52,28 +63,40 @@ def __init__(self, config: dict[str, str | int | bool]) -> None: ) def collect(self) -> Iterable[GaugeMetricFamily | CounterMetricFamily]: + """ + Gets Metric objects representing the current state of qbittorrent and yields + Prometheus gauges. + """ metrics: list[Metric] = self.get_qbittorrent_metrics() for metric in metrics: if metric.metric_type == MetricType.COUNTER: prom_metric = CounterMetricFamily( - metric.name, metric.help_text, labels=metric.labels.keys() + metric.name, metric.help_text, labels=list(metric.labels.keys()) ) else: prom_metric = GaugeMetricFamily( - metric.name, metric.help_text, labels=metric.labels.keys() + metric.name, metric.help_text, labels=list(metric.labels.keys()) ) - prom_metric.add_metric(value=metric.value, labels=metric.labels.values()) + prom_metric.add_metric( + value=metric.value, labels=list(metric.labels.values()) + ) yield prom_metric def get_qbittorrent_metrics(self) -> list[Metric]: + """ + Calls and combines qbittorrent state metrics with torrent metrics. + """ metrics: list[Metric] = [] metrics.extend(self._get_qbittorrent_status_metrics()) metrics.extend(self._get_qbittorrent_torrent_tags_metrics()) return metrics - def _get_qbittorrent_status_metrics(self) -> list[dict]: + def _get_qbittorrent_status_metrics(self) -> list[Metric]: + """ + Returns metrics about the state of the qbittorrent server. + """ response: dict[str, Any] = {} version: str = "" @@ -147,22 +170,24 @@ def _get_qbittorrent_torrent_tags_metrics(self) -> list[Metric]: return [] metrics: list[Metric] = [] - categories.Uncategorized = attridict({"name": "Uncategorized", "savePath": ""}) + + # Match torrents to categories for category in categories: - category_torrents = [ - t - for t in torrents - if t["category"] == category - or (category == "Uncategorized" and t["category"] == "") + category_torrents: list = [ + torrent + for torrent in torrents + if torrent["category"] == category + or (category == "Uncategorized" and torrent["category"] == "") ] - for status in TorrentStatus: - status_prop = f"is_{status.value}" + # Match qbittorrentapi torrent state to local TorrenStatuses + for status in TorrentStatuses: + proposed_status: str = f"is_{status.value}" status_torrents = [ - t - for t in category_torrents - if getattr(TorrentStates, status_prop).fget( - TorrentStates(t["state"]) + torrent + for torrent in category_torrents + if getattr(TorrentStates, proposed_status).fget( + TorrentStates(torrent["state"]) ) ] metrics.append( @@ -255,7 +280,7 @@ def main(): # Register our custom collector logger.info("Exporter is starting up") - REGISTRY.register(QbittorrentMetricsCollector(config)) + REGISTRY.register(QbittorrentMetricsCollector(config)) # type: ignore # Start server start_http_server(config["exporter_port"]) From 55e37eb814b33d081d34d8f92d303c4028e9b902 Mon Sep 17 00:00:00 2001 From: Joel Heaps Date: Fri, 29 Sep 2023 10:24:49 -0500 Subject: [PATCH 16/21] Use imported TorrenStates, decompose large method --- qbittorrent_exporter/exporter.py | 109 ++++++++++++++++--------------- 1 file changed, 58 insertions(+), 51 deletions(-) diff --git a/qbittorrent_exporter/exporter.py b/qbittorrent_exporter/exporter.py index 4d59637..65bfbb5 100644 --- a/qbittorrent_exporter/exporter.py +++ b/qbittorrent_exporter/exporter.py @@ -17,18 +17,6 @@ logger = logging.getLogger() -class TorrentStatuses(StrEnum): - """ - Represents possible torrent states. - """ - - CHECKING = auto() - COMPLETE = auto() - ERRORED = auto() - PAUSED = auto() - UPLOADING = auto() - - class MetricType(StrEnum): """ Represents possible metric types (used in this project). @@ -157,53 +145,72 @@ def _get_qbittorrent_status_metrics(self) -> list[Metric]: ), ] - def _get_qbittorrent_torrent_tags_metrics(self) -> list[Metric]: - """ - Returns Metric object containing number of torrents for each `category` and - `status`. - """ + def _fetch_categories(self) -> dict: + """Fetches all categories in use from qbittorrent.""" try: - categories = self.client.torrent_categories.categories - torrents = self.client.torrents.info() + categories = dict(self.client.torrent_categories.categories) + for key, value in categories.items(): + categories[key] = dict(value) # type: ignore + return categories except Exception as e: - logger.error(f"Couldn't fetch torrent info: {e}") + logger.error(f"Couldn't fetch categories: {e}") + return {} + + def _fetch_torrents(self) -> list[dict]: + """Fetches torrents from qbittorrent""" + try: + return [dict(_attr_dict) for _attr_dict in self.client.torrents.info()] + except Exception as e: + logger.error(f"Couldn't fetch torrents: {e}") return [] + def _filter_torrents_by_category( + self, category: str, torrents: list[dict] + ) -> list[dict]: + """Filters torrents by the given category.""" + return [ + torrent + for torrent in torrents + if torrent["category"] == category + or (category == "Uncategorized" and torrent["category"] == "") + ] + + def _filter_torrents_by_state( + self, state: TorrentStates, torrents: list[dict] + ) -> list[dict]: + """Filters torrents by the given state.""" + return [torrent for torrent in torrents if torrent["state"] == state.value] + + def _construct_metric(self, state: str, category: str, count: int) -> Metric: + """Constructs and returns a metric object with a torrent count and appropriate + labels.""" + return Metric( + name=f"{self.config['metrics_prefix']}_torrents_count", + value=count, + labels={ + "status": state, + "category": category, + }, + help_text=f"Number of torrents in status {state} under category {category}", + ) + + def _get_qbittorrent_torrent_tags_metrics(self) -> list[Metric]: + categories = self._fetch_categories() + torrents = self._fetch_torrents() + metrics: list[Metric] = [] + categories["Uncategorized"] = {"name": "Uncategorized", "savePath": ""} - # Match torrents to categories for category in categories: - category_torrents: list = [ - torrent - for torrent in torrents - if torrent["category"] == category - or (category == "Uncategorized" and torrent["category"] == "") - ] - - # Match qbittorrentapi torrent state to local TorrenStatuses - for status in TorrentStatuses: - proposed_status: str = f"is_{status.value}" - status_torrents = [ - torrent - for torrent in category_torrents - if getattr(TorrentStates, proposed_status).fget( - TorrentStates(torrent["state"]) - ) - ] - metrics.append( - Metric( - name=f"{self.config['metrics_prefix']}_torrents_count", - value=len(status_torrents), - labels={ - "status": status.value, - "category": category, - }, - help_text=( - f"Number of torrents in status {status.value} under" - f" category {category}" - ), - ) + category_torrents = self._filter_torrents_by_category(category, torrents) + for state in TorrentStates: + state_torrents = self._filter_torrents_by_state( + state, category_torrents + ) + metric = self._construct_metric( + state.value, category, len(state_torrents) ) + metrics.append(metric) return metrics From daed737f7394376cfee03945820d73c2a582452f Mon Sep 17 00:00:00 2001 From: Joel Heaps Date: Fri, 29 Sep 2023 10:48:26 -0500 Subject: [PATCH 17/21] Minor refactor of config functions, signal handler --- qbittorrent_exporter/exporter.py | 44 ++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/qbittorrent_exporter/exporter.py b/qbittorrent_exporter/exporter.py index 65bfbb5..2e135fe 100644 --- a/qbittorrent_exporter/exporter.py +++ b/qbittorrent_exporter/exporter.py @@ -215,26 +215,26 @@ def _get_qbittorrent_torrent_tags_metrics(self) -> list[Metric]: return metrics -class SignalHandler: +class ShutdownSignalHandler: def __init__(self): - self.shutdownCount = 0 + self.shutdown_count: int = 0 # Register signal handler signal.signal(signal.SIGINT, self._on_signal_received) signal.signal(signal.SIGTERM, self._on_signal_received) def is_shutting_down(self): - return self.shutdownCount > 0 + return self.shutdown_count > 0 def _on_signal_received(self, signal, frame): - if self.shutdownCount > 1: + if self.shutdown_count > 1: logger.warn("Forcibly killing exporter") sys.exit(1) logger.info("Exporter is shutting down") - self.shutdownCount += 1 + self.shutdown_count += 1 -def get_config_value(key, default=""): +def _get_config_value(key, default=""): input_path = os.environ.get("FILE__" + key, None) if input_path is not None: try: @@ -246,6 +246,22 @@ def get_config_value(key, default=""): return os.environ.get(key, default) +def get_config() -> dict: + """Loads all config values.""" + return { + "host": _get_config_value("QBITTORRENT_HOST", ""), + "port": _get_config_value("QBITTORRENT_PORT", ""), + "username": _get_config_value("QBITTORRENT_USER", ""), + "password": _get_config_value("QBITTORRENT_PASS", ""), + "exporter_port": int(_get_config_value("EXPORTER_PORT", "8000")), + "log_level": _get_config_value("EXPORTER_LOG_LEVEL", "INFO"), + "metrics_prefix": _get_config_value("METRICS_PREFIX", "qbittorrent"), + "verify_webui_certificate": ( + _get_config_value("VERIFY_WEBUI_CERTIFICATE", "True") == "True" + ), + } + + def main(): # Init logger so it can be used logHandler = logging.StreamHandler() @@ -256,23 +272,13 @@ def main(): logger.addHandler(logHandler) logger.setLevel("INFO") # default until config is loaded - config = { - "host": get_config_value("QBITTORRENT_HOST", ""), - "port": get_config_value("QBITTORRENT_PORT", ""), - "username": get_config_value("QBITTORRENT_USER", ""), - "password": get_config_value("QBITTORRENT_PASS", ""), - "exporter_port": int(get_config_value("EXPORTER_PORT", "8000")), - "log_level": get_config_value("EXPORTER_LOG_LEVEL", "INFO"), - "metrics_prefix": get_config_value("METRICS_PREFIX", "qbittorrent"), - "verify_webui_certificate": ( - get_config_value("VERIFY_WEBUI_CERTIFICATE", "True") == "True" - ), - } + config = get_config() + # set level once config has been loaded logger.setLevel(config["log_level"]) # Register signal handler - signal_handler = SignalHandler() + signal_handler = ShutdownSignalHandler() if not config["host"]: logger.error( From 330d8ec86aa461643765a582a4f37242a6caa833 Mon Sep 17 00:00:00 2001 From: Joel Heaps Date: Fri, 29 Sep 2023 10:50:01 -0500 Subject: [PATCH 18/21] Add type hints to get_config_value --- qbittorrent_exporter/exporter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qbittorrent_exporter/exporter.py b/qbittorrent_exporter/exporter.py index 2e135fe..093d219 100644 --- a/qbittorrent_exporter/exporter.py +++ b/qbittorrent_exporter/exporter.py @@ -234,7 +234,7 @@ def _on_signal_received(self, signal, frame): self.shutdown_count += 1 -def _get_config_value(key, default=""): +def _get_config_value(key: str, default: str = "") -> str: input_path = os.environ.get("FILE__" + key, None) if input_path is not None: try: From 71416a79ea3fc9e2003526a2cfca738c246cec1e Mon Sep 17 00:00:00 2001 From: Joel Heaps Date: Fri, 29 Sep 2023 10:51:52 -0500 Subject: [PATCH 19/21] Edit docstring --- qbittorrent_exporter/exporter.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/qbittorrent_exporter/exporter.py b/qbittorrent_exporter/exporter.py index 093d219..bae7608 100644 --- a/qbittorrent_exporter/exporter.py +++ b/qbittorrent_exporter/exporter.py @@ -52,8 +52,7 @@ def __init__(self, config: dict) -> None: def collect(self) -> Iterable[GaugeMetricFamily | CounterMetricFamily]: """ - Gets Metric objects representing the current state of qbittorrent and yields - Prometheus gauges. + Yields Prometheus gauges and counters from metrics collected from qbittorrent. """ metrics: list[Metric] = self.get_qbittorrent_metrics() From 4385a0a78fdb2ee23115fe200098d8937cfb401c Mon Sep 17 00:00:00 2001 From: Joel Heaps Date: Fri, 29 Sep 2023 11:09:10 -0500 Subject: [PATCH 20/21] Add config.env.example --- .gitignore | 2 +- config.env.example | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 config.env.example diff --git a/.gitignore b/.gitignore index 75b8fc6..71e81e9 100644 --- a/.gitignore +++ b/.gitignore @@ -128,5 +128,5 @@ dmypy.json # Pyre type checker .pyre/ -# Temporary ignore +# Ignore config.env config.env \ No newline at end of file diff --git a/config.env.example b/config.env.example new file mode 100644 index 0000000..55bbd2a --- /dev/null +++ b/config.env.example @@ -0,0 +1,6 @@ +QBITTORRENT_HOST=localhost +QBITTORRENT_PORT=8080 +QBITTORRENT_USER=admin +QBITTORRENT_PASS=adminadmin +EXPORTER_PORT=8000 +METRICS_PREFIX=qbittorrent \ No newline at end of file From 86b5feddf692e4cf4244d5778ebabadad635e54e Mon Sep 17 00:00:00 2001 From: Joel Heaps Date: Fri, 29 Sep 2023 17:28:20 +0000 Subject: [PATCH 21/21] Ignore and remove .pdm-python file --- .gitignore | 5 ++++- .pdm-python | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) delete mode 100644 .pdm-python diff --git a/.gitignore b/.gitignore index 71e81e9..97b792c 100644 --- a/.gitignore +++ b/.gitignore @@ -129,4 +129,7 @@ dmypy.json .pyre/ # Ignore config.env -config.env \ No newline at end of file +config.env + +# Ignore pdm local files +.pdm-python \ No newline at end of file diff --git a/.pdm-python b/.pdm-python deleted file mode 100644 index 49a44d4..0000000 --- a/.pdm-python +++ /dev/null @@ -1 +0,0 @@ -/Users/joel/Documents/projects 🚧/git/prometheus-qbittorrent-exporter/.venv/bin/python \ No newline at end of file