+ Coverage for qbittorrent_exporter/exporter.py: + 40% +
+ ++ 132 statements + + + +
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-20 12:15 +0000 +
+ +commit 9a8132624a96b807acb60cae1ed49e5ae756ccad Author: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon Nov 20 12:15:53 2023 +0000 Update coverage data diff --git a/README.md b/README.md new file mode 100644 index 0000000..099b692 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# Repository Coverage + +[Full report](https://htmlpreview.github.io/?https://github.com/esanchezm/prometheus-qbittorrent-exporter/blob/python-coverage-comment-action-data/htmlcov/index.html) + +| Name | Stmts | Miss | Cover | Missing | +|-------------------------------------- | -------: | -------: | ------: | --------: | +| qbittorrent\_exporter/\_\_init\_\_.py | 0 | 0 | 100% | | +| qbittorrent\_exporter/exporter.py | 132 | 79 | 40% |78-82, 88-98, 150-157, 161-165, 171, 182, 187, 198-215, 220-224, 227, 230-234, 238-246, 251, 267-305, 309 | +| **TOTAL** | **132** | **79** | **40%** | | + + +## Setup coverage badge + +Below are examples of the badges you can use in your main branch `README` file. + +### Direct image + +[](https://htmlpreview.github.io/?https://github.com/esanchezm/prometheus-qbittorrent-exporter/blob/python-coverage-comment-action-data/htmlcov/index.html) + +This is the one to use if your repository is private or if you don't want to customize anything. + +### [Shields.io](https://shields.io) Json Endpoint + +[](https://htmlpreview.github.io/?https://github.com/esanchezm/prometheus-qbittorrent-exporter/blob/python-coverage-comment-action-data/htmlcov/index.html) + +Using this one will allow you to [customize](https://shields.io/endpoint) the look of your badge. +It won't work with private repositories. It won't be refreshed more than once per five minutes. + +### [Shields.io](https://shields.io) Dynamic Badge + +[](https://htmlpreview.github.io/?https://github.com/esanchezm/prometheus-qbittorrent-exporter/blob/python-coverage-comment-action-data/htmlcov/index.html) + +This one will always be the same color. It won't work for private repos. I'm not even sure why we included it. + +## What is that? + +This branch is part of the +[python-coverage-comment-action](https://github.com/marketplace/actions/python-coverage-comment) +GitHub Action. All the files in this branch are automatically generated and may be +overwritten at any moment. \ No newline at end of file diff --git a/badge.svg b/badge.svg new file mode 100644 index 0000000..a70856d --- /dev/null +++ b/badge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data.json b/data.json new file mode 100644 index 0000000..66cabe8 --- /dev/null +++ b/data.json @@ -0,0 +1 @@ +{"coverage": 40.15151515151515, "raw_data": {"meta": {"version": "7.3.2", "timestamp": "2023-11-20T12:15:52.262875", "branch_coverage": false, "show_contexts": false}, "files": {"qbittorrent_exporter/__init__.py": {"executed_lines": [0], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "qbittorrent_exporter/exporter.py": {"executed_lines": [1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13, 14, 17, 18, 21, 22, 26, 27, 30, 31, 32, 36, 37, 38, 39, 40, 43, 44, 45, 46, 54, 58, 60, 61, 62, 66, 69, 72, 74, 84, 148, 159, 167, 178, 184, 197, 218, 219, 226, 229, 237, 249, 265, 308], "summary": {"covered_lines": 53, "num_statements": 132, "percent_covered": 40.15151515151515, "percent_covered_display": "40", "missing_lines": 79, "excluded_lines": 0}, "missing_lines": [78, 79, 80, 82, 88, 89, 92, 93, 94, 95, 96, 98, 150, 151, 152, 153, 154, 155, 156, 157, 161, 162, 163, 164, 165, 171, 182, 187, 198, 199, 201, 202, 204, 205, 206, 207, 210, 213, 215, 220, 223, 224, 227, 230, 231, 232, 233, 234, 238, 239, 240, 241, 242, 243, 244, 246, 251, 267, 268, 271, 272, 273, 275, 278, 281, 283, 284, 287, 288, 289, 292, 295, 296, 299, 300, 302, 303, 305, 309], "excluded_lines": []}}, "totals": {"covered_lines": 53, "num_statements": 132, "percent_covered": 40.15151515151515, "percent_covered_display": "40", "missing_lines": 79, "excluded_lines": 0}}, "coverage_path": "."} \ No newline at end of file diff --git a/endpoint.json b/endpoint.json new file mode 100644 index 0000000..19be703 --- /dev/null +++ b/endpoint.json @@ -0,0 +1 @@ +{"schemaVersion": 1, "label": "Coverage", "message": "40%", "color": "red"} \ No newline at end of file diff --git a/htmlcov/coverage_html.js b/htmlcov/coverage_html.js new file mode 100644 index 0000000..5934882 --- /dev/null +++ b/htmlcov/coverage_html.js @@ -0,0 +1,624 @@ +// Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 +// For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt + +// Coverage.py HTML report browser code. +/*jslint browser: true, sloppy: true, vars: true, plusplus: true, maxerr: 50, indent: 4 */ +/*global coverage: true, document, window, $ */ + +coverage = {}; + +// General helpers +function debounce(callback, wait) { + let timeoutId = null; + return function(...args) { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + callback.apply(this, args); + }, wait); + }; +}; + +function checkVisible(element) { + const rect = element.getBoundingClientRect(); + const viewBottom = Math.max(document.documentElement.clientHeight, window.innerHeight); + const viewTop = 30; + return !(rect.bottom < viewTop || rect.top >= viewBottom); +} + +function on_click(sel, fn) { + const elt = document.querySelector(sel); + if (elt) { + elt.addEventListener("click", fn); + } +} + +// Helpers for table sorting +function getCellValue(row, column = 0) { + const cell = row.cells[column] // nosemgrep: eslint.detect-object-injection + if (cell.childElementCount == 1) { + const child = cell.firstElementChild + if (child instanceof HTMLTimeElement && child.dateTime) { + return child.dateTime + } else if (child instanceof HTMLDataElement && child.value) { + return child.value + } + } + return cell.innerText || cell.textContent; +} + +function rowComparator(rowA, rowB, column = 0) { + let valueA = getCellValue(rowA, column); + let valueB = getCellValue(rowB, column); + if (!isNaN(valueA) && !isNaN(valueB)) { + return valueA - valueB + } + return valueA.localeCompare(valueB, undefined, {numeric: true}); +} + +function sortColumn(th) { + // Get the current sorting direction of the selected header, + // clear state on other headers and then set the new sorting direction + const currentSortOrder = th.getAttribute("aria-sort"); + [...th.parentElement.cells].forEach(header => header.setAttribute("aria-sort", "none")); + if (currentSortOrder === "none") { + th.setAttribute("aria-sort", th.dataset.defaultSortOrder || "ascending"); + } else { + th.setAttribute("aria-sort", currentSortOrder === "ascending" ? "descending" : "ascending"); + } + + const column = [...th.parentElement.cells].indexOf(th) + + // Sort all rows and afterwards append them in order to move them in the DOM + Array.from(th.closest("table").querySelectorAll("tbody tr")) + .sort((rowA, rowB) => rowComparator(rowA, rowB, column) * (th.getAttribute("aria-sort") === "ascending" ? 1 : -1)) + .forEach(tr => tr.parentElement.appendChild(tr) ); +} + +// Find all the elements with data-shortcut attribute, and use them to assign a shortcut key. +coverage.assign_shortkeys = function () { + document.querySelectorAll("[data-shortcut]").forEach(element => { + document.addEventListener("keypress", event => { + if (event.target.tagName.toLowerCase() === "input") { + return; // ignore keypress from search filter + } + if (event.key === element.dataset.shortcut) { + element.click(); + } + }); + }); +}; + +// Create the events for the filter box. +coverage.wire_up_filter = function () { + // Cache elements. + const table = document.querySelector("table.index"); + const table_body_rows = table.querySelectorAll("tbody tr"); + const no_rows = document.getElementById("no_rows"); + + // Observe filter keyevents. + document.getElementById("filter").addEventListener("input", debounce(event => { + // Keep running total of each metric, first index contains number of shown rows + const totals = new Array(table.rows[0].cells.length).fill(0); + // Accumulate the percentage as fraction + totals[totals.length - 1] = { "numer": 0, "denom": 0 }; // nosemgrep: eslint.detect-object-injection + + // Hide / show elements. + table_body_rows.forEach(row => { + if (!row.cells[0].textContent.includes(event.target.value)) { + // hide + row.classList.add("hidden"); + return; + } + + // show + row.classList.remove("hidden"); + totals[0]++; + + for (let column = 1; column < totals.length; column++) { + // Accumulate dynamic totals + cell = row.cells[column] // nosemgrep: eslint.detect-object-injection + if (column === totals.length - 1) { + // Last column contains percentage + const [numer, denom] = cell.dataset.ratio.split(" "); + totals[column]["numer"] += parseInt(numer, 10); // nosemgrep: eslint.detect-object-injection + totals[column]["denom"] += parseInt(denom, 10); // nosemgrep: eslint.detect-object-injection + } else { + totals[column] += parseInt(cell.textContent, 10); // nosemgrep: eslint.detect-object-injection + } + } + }); + + // Show placeholder if no rows will be displayed. + if (!totals[0]) { + // Show placeholder, hide table. + no_rows.style.display = "block"; + table.style.display = "none"; + return; + } + + // Hide placeholder, show table. + no_rows.style.display = null; + table.style.display = null; + + const footer = table.tFoot.rows[0]; + // Calculate new dynamic sum values based on visible rows. + for (let column = 1; column < totals.length; column++) { + // Get footer cell element. + const cell = footer.cells[column]; // nosemgrep: eslint.detect-object-injection + + // Set value into dynamic footer cell element. + if (column === totals.length - 1) { + // Percentage column uses the numerator and denominator, + // and adapts to the number of decimal places. + const match = /\.([0-9]+)/.exec(cell.textContent); + const places = match ? match[1].length : 0; + const { numer, denom } = totals[column]; // nosemgrep: eslint.detect-object-injection + cell.dataset.ratio = `${numer} ${denom}`; + // Check denom to prevent NaN if filtered files contain no statements + cell.textContent = denom + ? `${(numer * 100 / denom).toFixed(places)}%` + : `${(100).toFixed(places)}%`; + } else { + cell.textContent = totals[column]; // nosemgrep: eslint.detect-object-injection + } + } + })); + + // Trigger change event on setup, to force filter on page refresh + // (filter value may still be present). + document.getElementById("filter").dispatchEvent(new Event("input")); +}; + +coverage.INDEX_SORT_STORAGE = "COVERAGE_INDEX_SORT_2"; + +// Loaded on index.html +coverage.index_ready = function () { + coverage.assign_shortkeys(); + coverage.wire_up_filter(); + document.querySelectorAll("[data-sortable] th[aria-sort]").forEach( + th => th.addEventListener("click", e => sortColumn(e.target)) + ); + + // Look for a localStorage item containing previous sort settings: + const stored_list = localStorage.getItem(coverage.INDEX_SORT_STORAGE); + + if (stored_list) { + const {column, direction} = JSON.parse(stored_list); + const th = document.querySelector("[data-sortable]").tHead.rows[0].cells[column]; // nosemgrep: eslint.detect-object-injection + th.setAttribute("aria-sort", direction === "ascending" ? "descending" : "ascending"); + th.click() + } + + // Watch for page unload events so we can save the final sort settings: + window.addEventListener("unload", function () { + const th = document.querySelector('[data-sortable] th[aria-sort="ascending"], [data-sortable] [aria-sort="descending"]'); + if (!th) { + return; + } + localStorage.setItem(coverage.INDEX_SORT_STORAGE, JSON.stringify({ + column: [...th.parentElement.cells].indexOf(th), + direction: th.getAttribute("aria-sort"), + })); + }); + + on_click(".button_prev_file", coverage.to_prev_file); + on_click(".button_next_file", coverage.to_next_file); + + on_click(".button_show_hide_help", coverage.show_hide_help); +}; + +// -- pyfile stuff -- + +coverage.LINE_FILTERS_STORAGE = "COVERAGE_LINE_FILTERS"; + +coverage.pyfile_ready = function () { + // If we're directed to a particular line number, highlight the line. + var frag = location.hash; + if (frag.length > 2 && frag[1] === "t") { + document.querySelector(frag).closest(".n").classList.add("highlight"); + coverage.set_sel(parseInt(frag.substr(2), 10)); + } else { + coverage.set_sel(0); + } + + on_click(".button_toggle_run", coverage.toggle_lines); + on_click(".button_toggle_mis", coverage.toggle_lines); + on_click(".button_toggle_exc", coverage.toggle_lines); + on_click(".button_toggle_par", coverage.toggle_lines); + + on_click(".button_next_chunk", coverage.to_next_chunk_nicely); + on_click(".button_prev_chunk", coverage.to_prev_chunk_nicely); + on_click(".button_top_of_page", coverage.to_top); + on_click(".button_first_chunk", coverage.to_first_chunk); + + on_click(".button_prev_file", coverage.to_prev_file); + on_click(".button_next_file", coverage.to_next_file); + on_click(".button_to_index", coverage.to_index); + + on_click(".button_show_hide_help", coverage.show_hide_help); + + coverage.filters = undefined; + try { + coverage.filters = localStorage.getItem(coverage.LINE_FILTERS_STORAGE); + } catch(err) {} + + if (coverage.filters) { + coverage.filters = JSON.parse(coverage.filters); + } + else { + coverage.filters = {run: false, exc: true, mis: true, par: true}; + } + + for (cls in coverage.filters) { + coverage.set_line_visibilty(cls, coverage.filters[cls]); // nosemgrep: eslint.detect-object-injection + } + + coverage.assign_shortkeys(); + coverage.init_scroll_markers(); + coverage.wire_up_sticky_header(); + + document.querySelectorAll("[id^=ctxs]").forEach( + cbox => cbox.addEventListener("click", coverage.expand_contexts) + ); + + // Rebuild scroll markers when the window height changes. + window.addEventListener("resize", coverage.build_scroll_markers); +}; + +coverage.toggle_lines = function (event) { + const btn = event.target.closest("button"); + const category = btn.value + const show = !btn.classList.contains("show_" + category); + coverage.set_line_visibilty(category, show); + coverage.build_scroll_markers(); + coverage.filters[category] = show; + try { + localStorage.setItem(coverage.LINE_FILTERS_STORAGE, JSON.stringify(coverage.filters)); + } catch(err) {} +}; + +coverage.set_line_visibilty = function (category, should_show) { + const cls = "show_" + category; + const btn = document.querySelector(".button_toggle_" + category); + if (btn) { + if (should_show) { + document.querySelectorAll("#source ." + category).forEach(e => e.classList.add(cls)); + btn.classList.add(cls); + } + else { + document.querySelectorAll("#source ." + category).forEach(e => e.classList.remove(cls)); + btn.classList.remove(cls); + } + } +}; + +// Return the nth line div. +coverage.line_elt = function (n) { + return document.getElementById("t" + n)?.closest("p"); +}; + +// Set the selection. b and e are line numbers. +coverage.set_sel = function (b, e) { + // The first line selected. + coverage.sel_begin = b; + // The next line not selected. + coverage.sel_end = (e === undefined) ? b+1 : e; +}; + +coverage.to_top = function () { + coverage.set_sel(0, 1); + coverage.scroll_window(0); +}; + +coverage.to_first_chunk = function () { + coverage.set_sel(0, 1); + coverage.to_next_chunk(); +}; + +coverage.to_prev_file = function () { + window.location = document.getElementById("prevFileLink").href; +} + +coverage.to_next_file = function () { + window.location = document.getElementById("nextFileLink").href; +} + +coverage.to_index = function () { + location.href = document.getElementById("indexLink").href; +} + +coverage.show_hide_help = function () { + const helpCheck = document.getElementById("help_panel_state") + helpCheck.checked = !helpCheck.checked; +} + +// Return a string indicating what kind of chunk this line belongs to, +// or null if not a chunk. +coverage.chunk_indicator = function (line_elt) { + const classes = line_elt?.className; + if (!classes) { + return null; + } + const match = classes.match(/\bshow_\w+\b/); + if (!match) { + return null; + } + return match[0]; +}; + +coverage.to_next_chunk = function () { + const c = coverage; + + // Find the start of the next colored chunk. + var probe = c.sel_end; + var chunk_indicator, probe_line; + while (true) { + probe_line = c.line_elt(probe); + if (!probe_line) { + return; + } + chunk_indicator = c.chunk_indicator(probe_line); + if (chunk_indicator) { + break; + } + probe++; + } + + // There's a next chunk, `probe` points to it. + var begin = probe; + + // Find the end of this chunk. + var next_indicator = chunk_indicator; + while (next_indicator === chunk_indicator) { + probe++; + probe_line = c.line_elt(probe); + next_indicator = c.chunk_indicator(probe_line); + } + c.set_sel(begin, probe); + c.show_selection(); +}; + +coverage.to_prev_chunk = function () { + const c = coverage; + + // Find the end of the prev colored chunk. + var probe = c.sel_begin-1; + var probe_line = c.line_elt(probe); + if (!probe_line) { + return; + } + var chunk_indicator = c.chunk_indicator(probe_line); + while (probe > 1 && !chunk_indicator) { + probe--; + probe_line = c.line_elt(probe); + if (!probe_line) { + return; + } + chunk_indicator = c.chunk_indicator(probe_line); + } + + // There's a prev chunk, `probe` points to its last line. + var end = probe+1; + + // Find the beginning of this chunk. + var prev_indicator = chunk_indicator; + while (prev_indicator === chunk_indicator) { + probe--; + if (probe <= 0) { + return; + } + probe_line = c.line_elt(probe); + prev_indicator = c.chunk_indicator(probe_line); + } + c.set_sel(probe+1, end); + c.show_selection(); +}; + +// Returns 0, 1, or 2: how many of the two ends of the selection are on +// the screen right now? +coverage.selection_ends_on_screen = function () { + if (coverage.sel_begin === 0) { + return 0; + } + + const begin = coverage.line_elt(coverage.sel_begin); + const end = coverage.line_elt(coverage.sel_end-1); + + return ( + (checkVisible(begin) ? 1 : 0) + + (checkVisible(end) ? 1 : 0) + ); +}; + +coverage.to_next_chunk_nicely = function () { + if (coverage.selection_ends_on_screen() === 0) { + // The selection is entirely off the screen: + // Set the top line on the screen as selection. + + // This will select the top-left of the viewport + // As this is most likely the span with the line number we take the parent + const line = document.elementFromPoint(0, 0).parentElement; + if (line.parentElement !== document.getElementById("source")) { + // The element is not a source line but the header or similar + coverage.select_line_or_chunk(1); + } else { + // We extract the line number from the id + coverage.select_line_or_chunk(parseInt(line.id.substring(1), 10)); + } + } + coverage.to_next_chunk(); +}; + +coverage.to_prev_chunk_nicely = function () { + if (coverage.selection_ends_on_screen() === 0) { + // The selection is entirely off the screen: + // Set the lowest line on the screen as selection. + + // This will select the bottom-left of the viewport + // As this is most likely the span with the line number we take the parent + const line = document.elementFromPoint(document.documentElement.clientHeight-1, 0).parentElement; + if (line.parentElement !== document.getElementById("source")) { + // The element is not a source line but the header or similar + coverage.select_line_or_chunk(coverage.lines_len); + } else { + // We extract the line number from the id + coverage.select_line_or_chunk(parseInt(line.id.substring(1), 10)); + } + } + coverage.to_prev_chunk(); +}; + +// Select line number lineno, or if it is in a colored chunk, select the +// entire chunk +coverage.select_line_or_chunk = function (lineno) { + var c = coverage; + var probe_line = c.line_elt(lineno); + if (!probe_line) { + return; + } + var the_indicator = c.chunk_indicator(probe_line); + if (the_indicator) { + // The line is in a highlighted chunk. + // Search backward for the first line. + var probe = lineno; + var indicator = the_indicator; + while (probe > 0 && indicator === the_indicator) { + probe--; + probe_line = c.line_elt(probe); + if (!probe_line) { + break; + } + indicator = c.chunk_indicator(probe_line); + } + var begin = probe + 1; + + // Search forward for the last line. + probe = lineno; + indicator = the_indicator; + while (indicator === the_indicator) { + probe++; + probe_line = c.line_elt(probe); + indicator = c.chunk_indicator(probe_line); + } + + coverage.set_sel(begin, probe); + } + else { + coverage.set_sel(lineno); + } +}; + +coverage.show_selection = function () { + // Highlight the lines in the chunk + document.querySelectorAll("#source .highlight").forEach(e => e.classList.remove("highlight")); + for (let probe = coverage.sel_begin; probe < coverage.sel_end; probe++) { + coverage.line_elt(probe).querySelector(".n").classList.add("highlight"); + } + + coverage.scroll_to_selection(); +}; + +coverage.scroll_to_selection = function () { + // Scroll the page if the chunk isn't fully visible. + if (coverage.selection_ends_on_screen() < 2) { + const element = coverage.line_elt(coverage.sel_begin); + coverage.scroll_window(element.offsetTop - 60); + } +}; + +coverage.scroll_window = function (to_pos) { + window.scroll({top: to_pos, behavior: "smooth"}); +}; + +coverage.init_scroll_markers = function () { + // Init some variables + coverage.lines_len = document.querySelectorAll("#source > p").length; + + // Build html + coverage.build_scroll_markers(); +}; + +coverage.build_scroll_markers = function () { + const temp_scroll_marker = document.getElementById("scroll_marker") + if (temp_scroll_marker) temp_scroll_marker.remove(); + // Don't build markers if the window has no scroll bar. + if (document.body.scrollHeight <= window.innerHeight) { + return; + } + + const marker_scale = window.innerHeight / document.body.scrollHeight; + const line_height = Math.min(Math.max(3, window.innerHeight / coverage.lines_len), 10); + + let previous_line = -99, last_mark, last_top; + + const scroll_marker = document.createElement("div"); + scroll_marker.id = "scroll_marker"; + document.getElementById("source").querySelectorAll( + "p.show_run, p.show_mis, p.show_exc, p.show_exc, p.show_par" + ).forEach(element => { + const line_top = Math.floor(element.offsetTop * marker_scale); + const line_number = parseInt(element.querySelector(".n a").id.substr(1)); + + if (line_number === previous_line + 1) { + // If this solid missed block just make previous mark higher. + last_mark.style.height = `${line_top + line_height - last_top}px`; + } else { + // Add colored line in scroll_marker block. + last_mark = document.createElement("div"); + last_mark.id = `m${line_number}`; + last_mark.classList.add("marker"); + last_mark.style.height = `${line_height}px`; + last_mark.style.top = `${line_top}px`; + scroll_marker.append(last_mark); + last_top = line_top; + } + + previous_line = line_number; + }); + + // Append last to prevent layout calculation + document.body.append(scroll_marker); +}; + +coverage.wire_up_sticky_header = function () { + const header = document.querySelector("header"); + const header_bottom = ( + header.querySelector(".content h2").getBoundingClientRect().top - + header.getBoundingClientRect().top + ); + + function updateHeader() { + if (window.scrollY > header_bottom) { + header.classList.add("sticky"); + } else { + header.classList.remove("sticky"); + } + } + + window.addEventListener("scroll", updateHeader); + updateHeader(); +}; + +coverage.expand_contexts = function (e) { + var ctxs = e.target.parentNode.querySelector(".ctxs"); + + if (!ctxs.classList.contains("expanded")) { + var ctxs_text = ctxs.textContent; + var width = Number(ctxs_text[0]); + ctxs.textContent = ""; + for (var i = 1; i < ctxs_text.length; i += width) { + key = ctxs_text.substring(i, i + width).trim(); + ctxs.appendChild(document.createTextNode(contexts[key])); + ctxs.appendChild(document.createElement("br")); + } + ctxs.classList.add("expanded"); + } +}; + +document.addEventListener("DOMContentLoaded", () => { + if (document.body.classList.contains("indexfile")) { + coverage.index_ready(); + } else { + coverage.pyfile_ready(); + } +}); diff --git a/htmlcov/d_6abd174c694c1177_exporter_py.html b/htmlcov/d_6abd174c694c1177_exporter_py.html new file mode 100644 index 0000000..b74e289 --- /dev/null +++ b/htmlcov/d_6abd174c694c1177_exporter_py.html @@ -0,0 +1,406 @@ + + +
+ ++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-20 12:15 +0000 +
+ +1import faulthandler
+2import logging
+3import os
+4import signal
+5import sys
+6import time
+7from dataclasses import dataclass, field
+8from enum import StrEnum, auto
+9from typing import Any, Iterable
+ +11from prometheus_client import start_http_server
+12from prometheus_client.core import REGISTRY, CounterMetricFamily, GaugeMetricFamily
+13from pythonjsonlogger import jsonlogger
+14from qbittorrentapi import Client, TorrentStates
+ +16# Enable dumps on stderr in case of segfault
+17faulthandler.enable()
+18logger = logging.getLogger()
+ + +21class MetricType(StrEnum):
+22 """
+23 Represents possible metric types (used in this project).
+24 """
+ +26 GAUGE = auto()
+27 COUNTER = auto()
+ + +30@dataclass
+31class Metric:
+32 """
+33 Contains data and metadata about a single counter or gauge.
+34 """
+ +36 name: str
+37 value: Any
+38 labels: dict[str, str] = field(default_factory=lambda: {}) # Default to empty dict
+39 help_text: str = ""
+40 metric_type: MetricType = MetricType.GAUGE
+ + +43class QbittorrentMetricsCollector:
+44 def __init__(self, config: dict) -> None:
+45 self.config = config
+46 self.client = Client(
+47 host=config["host"],
+48 port=config["port"],
+49 username=config["username"],
+50 password=config["password"],
+51 VERIFY_WEBUI_CERTIFICATE=config["verify_webui_certificate"],
+52 )
+ +54 def collect(self) -> Iterable[GaugeMetricFamily | CounterMetricFamily]:
+55 """
+56 Yields Prometheus gauges and counters from metrics collected from qbittorrent.
+57 """
+58 metrics: list[Metric] = self.get_qbittorrent_metrics()
+ +60 for metric in metrics:
+61 if metric.metric_type == MetricType.COUNTER:
+62 prom_metric = CounterMetricFamily(
+63 metric.name, metric.help_text, labels=list(metric.labels.keys())
+64 )
+65 else:
+66 prom_metric = GaugeMetricFamily(
+67 metric.name, metric.help_text, labels=list(metric.labels.keys())
+68 )
+69 prom_metric.add_metric(
+70 value=metric.value, labels=list(metric.labels.values())
+71 )
+72 yield prom_metric
+ +74 def get_qbittorrent_metrics(self) -> list[Metric]:
+75 """
+76 Calls and combines qbittorrent state metrics with torrent metrics.
+77 """
+78 metrics: list[Metric] = []
+79 metrics.extend(self._get_qbittorrent_status_metrics())
+80 metrics.extend(self._get_qbittorrent_torrent_tags_metrics())
+ +82 return metrics
+ +84 def _get_qbittorrent_status_metrics(self) -> list[Metric]:
+85 """
+86 Returns metrics about the state of the qbittorrent server.
+87 """
+88 response: dict[str, Any] = {}
+89 version: str = ""
+ +91 # Fetch data from API
+92 try:
+93 response = self.client.transfer.info
+94 version = self.client.app.version
+95 except Exception as e:
+96 logger.error(f"Couldn't get server info: {e}")
+ +98 return [
+99 Metric(
+100 name=f"{self.config['metrics_prefix']}_up",
+101 value=bool(response),
+102 labels={"version": version},
+103 help_text=(
+104 "Whether the qBittorrent server is answering requests from this"
+105 " exporter. A `version` label with the server version is added."
+106 ),
+107 ),
+108 Metric(
+109 name=f"{self.config['metrics_prefix']}_connected",
+110 value=response.get("connection_status", "") == "connected",
+111 labels={}, # no labels in the example
+112 help_text=(
+113 "Whether the qBittorrent server is connected to the Bittorrent"
+114 " network."
+115 ),
+116 ),
+117 Metric(
+118 name=f"{self.config['metrics_prefix']}_firewalled",
+119 value=response.get("connection_status", "") == "firewalled",
+120 labels={}, # no labels in the example
+121 help_text=(
+122 "Whether the qBittorrent server is connected to the Bittorrent"
+123 " network but is behind a firewall."
+124 ),
+125 ),
+126 Metric(
+127 name=f"{self.config['metrics_prefix']}_dht_nodes",
+128 value=response.get("dht_nodes", 0),
+129 labels={}, # no labels in the example
+130 help_text="Number of DHT nodes connected to.",
+131 ),
+132 Metric(
+133 name=f"{self.config['metrics_prefix']}_dl_info_data",
+134 value=response.get("dl_info_data", 0),
+135 labels={}, # no labels in the example
+136 help_text="Data downloaded since the server started, in bytes.",
+137 metric_type=MetricType.COUNTER,
+138 ),
+139 Metric(
+140 name=f"{self.config['metrics_prefix']}_up_info_data",
+141 value=response.get("up_info_data", 0),
+142 labels={}, # no labels in the example
+143 help_text="Data uploaded since the server started, in bytes.",
+144 metric_type=MetricType.COUNTER,
+145 ),
+146 ]
+ +148 def _fetch_categories(self) -> dict:
+149 """Fetches all categories in use from qbittorrent."""
+150 try:
+151 categories = dict(self.client.torrent_categories.categories)
+152 for key, value in categories.items():
+153 categories[key] = dict(value) # type: ignore
+154 return categories
+155 except Exception as e:
+156 logger.error(f"Couldn't fetch categories: {e}")
+157 return {}
+ +159 def _fetch_torrents(self) -> list[dict]:
+160 """Fetches torrents from qbittorrent"""
+161 try:
+162 return [dict(_attr_dict) for _attr_dict in self.client.torrents.info()]
+163 except Exception as e:
+164 logger.error(f"Couldn't fetch torrents: {e}")
+165 return []
+ +167 def _filter_torrents_by_category(
+168 self, category: str, torrents: list[dict]
+169 ) -> list[dict]:
+170 """Filters torrents by the given category."""
+171 return [
+172 torrent
+173 for torrent in torrents
+174 if torrent["category"] == category
+175 or (category == "Uncategorized" and torrent["category"] == "")
+176 ]
+ +178 def _filter_torrents_by_state(
+179 self, state: TorrentStates, torrents: list[dict]
+180 ) -> list[dict]:
+181 """Filters torrents by the given state."""
+182 return [torrent for torrent in torrents if torrent["state"] == state.value]
+ +184 def _construct_metric(self, state: str, category: str, count: int) -> Metric:
+185 """Constructs and returns a metric object with a torrent count and appropriate
+186 labels."""
+187 return Metric(
+188 name=f"{self.config['metrics_prefix']}_torrents_count",
+189 value=count,
+190 labels={
+191 "status": state,
+192 "category": category,
+193 },
+194 help_text=f"Number of torrents in status {state} under category {category}",
+195 )
+ +197 def _get_qbittorrent_torrent_tags_metrics(self) -> list[Metric]:
+198 categories = self._fetch_categories()
+199 torrents = self._fetch_torrents()
+ +201 metrics: list[Metric] = []
+202 categories["Uncategorized"] = {"name": "Uncategorized", "savePath": ""}
+ +204 for category in categories:
+205 category_torrents = self._filter_torrents_by_category(category, torrents)
+206 for state in TorrentStates:
+207 state_torrents = self._filter_torrents_by_state(
+208 state, category_torrents
+209 )
+210 metric = self._construct_metric(
+211 state.value, category, len(state_torrents)
+212 )
+213 metrics.append(metric)
+ +215 return metrics
+ + +218class ShutdownSignalHandler:
+219 def __init__(self):
+220 self.shutdown_count: int = 0
+ +222 # Register signal handler
+223 signal.signal(signal.SIGINT, self._on_signal_received)
+224 signal.signal(signal.SIGTERM, self._on_signal_received)
+ +226 def is_shutting_down(self):
+227 return self.shutdown_count > 0
+ +229 def _on_signal_received(self, signal, frame):
+230 if self.shutdown_count > 1:
+231 logger.warn("Forcibly killing exporter")
+232 sys.exit(1)
+233 logger.info("Exporter is shutting down")
+234 self.shutdown_count += 1
+ + +237def _get_config_value(key: str, default: str = "") -> str:
+238 input_path = os.environ.get("FILE__" + key, None)
+239 if input_path is not None:
+240 try:
+241 with open(input_path, "r") as input_file:
+242 return input_file.read().strip()
+243 except IOError as e:
+244 logger.error(f"Unable to read value for {key} from {input_path}: {str(e)}")
+ +246 return os.environ.get(key, default)
+ + +249def get_config() -> dict:
+250 """Loads all config values."""
+251 return {
+252 "host": _get_config_value("QBITTORRENT_HOST", ""),
+253 "port": _get_config_value("QBITTORRENT_PORT", ""),
+254 "username": _get_config_value("QBITTORRENT_USER", ""),
+255 "password": _get_config_value("QBITTORRENT_PASS", ""),
+256 "exporter_port": int(_get_config_value("EXPORTER_PORT", "8000")),
+257 "log_level": _get_config_value("EXPORTER_LOG_LEVEL", "INFO"),
+258 "metrics_prefix": _get_config_value("METRICS_PREFIX", "qbittorrent"),
+259 "verify_webui_certificate": (
+260 _get_config_value("VERIFY_WEBUI_CERTIFICATE", "True") == "True"
+261 ),
+262 }
+ + +265def main():
+266 # Init logger so it can be used
+267 logHandler = logging.StreamHandler()
+268 formatter = jsonlogger.JsonFormatter(
+269 "%(asctime) %(levelname) %(message)", datefmt="%Y-%m-%d %H:%M:%S"
+270 )
+271 logHandler.setFormatter(formatter)
+272 logger.addHandler(logHandler)
+273 logger.setLevel("INFO") # default until config is loaded
+ +275 config = get_config()
+ +277 # set level once config has been loaded
+278 logger.setLevel(config["log_level"])
+ +280 # Register signal handler
+281 signal_handler = ShutdownSignalHandler()
+ +283 if not config["host"]:
+284 logger.error(
+285 "No host specified, please set QBITTORRENT_HOST environment variable"
+286 )
+287 sys.exit(1)
+288 if not config["port"]:
+289 logger.error(
+290 "No port specified, please set QBITTORRENT_PORT environment variable"
+291 )
+292 sys.exit(1)
+ +294 # Register our custom collector
+295 logger.info("Exporter is starting up")
+296 REGISTRY.register(QbittorrentMetricsCollector(config)) # type: ignore
+ +298 # Start server
+299 start_http_server(config["exporter_port"])
+300 logger.info(f"Exporter listening on port {config['exporter_port']}")
+ +302 while not signal_handler.is_shutting_down():
+303 time.sleep(1)
+ +305 logger.info("Exporter has shutdown")
+ + +308if __name__ == "__main__":
+309 main()
++ coverage.py v7.3.2, + created at 2023-11-20 12:15 +0000 +
+Module | +statements | +missing | +excluded | +coverage | +
---|---|---|---|---|
qbittorrent_exporter/exporter.py | +132 | +79 | +0 | +40% | +
Total | +132 | +79 | +0 | +40% | +
+ No items found using the specified filter. +
+1 empty file skipped.
+