From a09bdb5f0b256c695f40ecfdf1fc3c1edf6bdb13 Mon Sep 17 00:00:00 2001 From: bobokun <12660469+bobokun@users.noreply.github.com> Date: Sun, 9 Feb 2025 19:12:32 -0500 Subject: [PATCH] 4.1.17 (#744) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 4.1.17-develop1 * Retry on ConnectionError (#740) Add Retries for connection to qbit * Adds !ENV constructor to read environment variables * Update config sample to include ENV variable examples * Fixes #702 * remove warning when remote_dir not defined * [pre-commit.ci] pre-commit autoupdate (#742) * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/pycqa/isort: 5.13.2 → 6.0.0](https://github.com/pycqa/isort/compare/5.13.2...6.0.0) - [github.com/psf/black: 24.10.0 → 25.1.0](https://github.com/psf/black/compare/24.10.0...25.1.0) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * add more !ENV usage in config.yml.sample * 4.1.17 * formatting --------- Co-authored-by: Denys Kozhevnikov Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- CHANGELOG | 11 +++++---- VERSION | 2 +- config/config.yml.sample | 7 +++--- docs/Config-Setup.md | 4 +++- modules/config.py | 51 ++++++++++++++++++++++++++++++---------- modules/core/__init__.py | 2 +- modules/qbittorrent.py | 4 ++++ modules/util.py | 43 +++++++++++++++++++++++++++++---- 9 files changed, 99 insertions(+), 29 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b6c4a068..04f0fa41 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,7 +32,7 @@ repos: - id: yamlfix exclude: ^.github/ - repo: https://github.com/pycqa/isort - rev: 5.13.2 + rev: 6.0.0 hooks: - id: isort name: isort (python) @@ -43,7 +43,7 @@ repos: - id: pyupgrade args: [--py3-plus] - repo: https://github.com/psf/black - rev: 24.10.0 + rev: 25.1.0 hooks: - id: black language_version: python3 diff --git a/CHANGELOG b/CHANGELOG index 56dd2838..d5734e54 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,7 +1,8 @@ -# Requirements Updated -ruamel.yaml==0.18.10 - # New Updates -- Adds support for wlidcard matching in category (Adds #695) +- Adds support for environment variables in config file using (`!ENV VAR_NAME`) +- Add Retries on ConnectionError (#740) +- Fixes #702 +- Removes warning when `remote_dir` is not defined -**Full Changelog**: https://github.com/StuffAnThings/qbit_manage/compare/v4.1.15...v4.1.16 +Special thanks to @NooNameR for their contributions! +**Full Changelog**: https://github.com/StuffAnThings/qbit_manage/compare/v4.1.16...v4.1.17 diff --git a/VERSION b/VERSION index 1b94a070..73274fd6 100755 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.1.16 +4.1.17 diff --git a/config/config.yml.sample b/config/config.yml.sample index cfb89e48..94f419f3 100755 --- a/config/config.yml.sample +++ b/config/config.yml.sample @@ -20,9 +20,10 @@ commands: qbt: # qBittorrent parameters + # Pass environment variables to the config via !ENV tag host: "localhost:8080" - user: "username" - pass: "password" + user: !ENV QBIT_USER + pass: !ENV QBIT_PASS settings: force_auto_tmm: False # Will force qBittorrent to enable Automatic Torrent Management for each torrent. @@ -295,7 +296,7 @@ notifiarr: # Notifiarr integration with webhooks # Leave Empty/Blank to disable # Mandatory to fill out API Key - apikey: #################################### + apikey: !ENV NOTIFIARR_API # Set to a unique value (could be your username on notifiarr for example) instance: diff --git a/docs/Config-Setup.md b/docs/Config-Setup.md index ba94c9ec..4f8dfb40 100644 --- a/docs/Config-Setup.md +++ b/docs/Config-Setup.md @@ -3,10 +3,12 @@ The script utilizes a YAML config file to load information to connect to the various APIs you can connect with. -By default, the script looks at /config/config.yml for the Configuration File unless otherwise specified. +By default, the script looks at `/config/config.yml` when running locally or `/app/config.yml` in docker for the Configuration File unless otherwise specified. A template Configuration File can be found in the repo [config/config.yml.sample](https://github.com/StuffAnThings/qbit_manage/blob/master/config/config.yml.sample). +You can reference environment variables inside your `config.yml` by `!ENV VAR_NAME` + **WARNING**: As this software is constantly evolving and this wiki might not be up to date the sample shown here might not might not be current. Please refer to the repo for the most current version. # Config File diff --git a/modules/config.py b/modules/config.py index 08594549..9652b64a 100755 --- a/modules/config.py +++ b/modules/config.py @@ -657,16 +657,31 @@ def _sort_share_limits(share_limits): self.util.check_for_attribute(self.data, "root_dir", parent="directory", default_is_none=True), "" ) self.remote_dir = os.path.join( - self.util.check_for_attribute(self.data, "remote_dir", parent="directory", default=self.root_dir), "" + self.util.check_for_attribute( + self.data, "remote_dir", parent="directory", default=self.root_dir, do_print=False, save=False + ), + "", ) if self.commands["cross_seed"] or self.commands["tag_nohardlinks"] or self.commands["rem_orphaned"]: self.remote_dir = self.util.check_for_attribute( - self.data, "remote_dir", parent="directory", var_type="path", default=self.root_dir + self.data, + "remote_dir", + parent="directory", + var_type="path", + default=self.root_dir, + do_print=False, + save=False, ) else: if self.recyclebin["enabled"]: self.remote_dir = self.util.check_for_attribute( - self.data, "remote_dir", parent="directory", var_type="path", default=self.root_dir + self.data, + "remote_dir", + parent="directory", + var_type="path", + default=self.root_dir, + do_print=False, + save=False, ) if not self.remote_dir: self.remote_dir = self.root_dir @@ -754,20 +769,32 @@ def _sort_share_limits(share_limits): # Connect to Qbittorrent self.qbt = None if "qbt" in self.data: - logger.info("Connecting to Qbittorrent...") - self.qbt = Qbt( - self, - { - "host": self.util.check_for_attribute(self.data, "host", parent="qbt", throw=True), - "username": self.util.check_for_attribute(self.data, "user", parent="qbt", default_is_none=True), - "password": self.util.check_for_attribute(self.data, "pass", parent="qbt", default_is_none=True), - }, - ) + self.qbt = self.__connect() else: e = "Config Error: qbt attribute not found" self.notify(e, "Config") raise Failed(e) + def __retry_on_connect(exception): + return isinstance(exception.__cause__, ConnectionError) + + @retry( + retry_on_exception=__retry_on_connect, + stop_max_attempt_number=5, + wait_exponential_multiplier=30000, + wait_exponential_max=120000, + ) + def __connect(self): + logger.info("Connecting to Qbittorrent...") + return Qbt( + self, + { + "host": self.util.check_for_attribute(self.data, "host", parent="qbt", throw=True), + "username": self.util.check_for_attribute(self.data, "user", parent="qbt", default_is_none=True), + "password": self.util.check_for_attribute(self.data, "pass", parent="qbt", default_is_none=True), + }, + ) + # Empty old files from recycle bin or orphaned def cleanup_dirs(self, location): num_del = 0 diff --git a/modules/core/__init__.py b/modules/core/__init__.py index a2859050..f6208639 100644 --- a/modules/core/__init__.py +++ b/modules/core/__init__.py @@ -1,3 +1,3 @@ """ - modules.core contains all the core functions of qbit_manage such as updating categories/tags etc.. +modules.core contains all the core functions of qbit_manage such as updating categories/tags etc.. """ diff --git a/modules/qbittorrent.py b/modules/qbittorrent.py index 4aeefc12..1b0c690e 100755 --- a/modules/qbittorrent.py +++ b/modules/qbittorrent.py @@ -5,6 +5,7 @@ from fnmatch import fnmatch from functools import cache +from qbittorrentapi import APIConnectionError from qbittorrentapi import Client from qbittorrentapi import LoginFailed from qbittorrentapi import NotFound404Error @@ -77,6 +78,9 @@ def __init__(self, config, params): ex = "Qbittorrent Error: Failed to login. Invalid username/password." self.config.notify(ex, "Qbittorrent") raise Failed(ex) + except APIConnectionError as exc: + self.config.notify(exc, "Qbittorrent") + raise Failed(exc) from ConnectionError(exc) except Exception as exc: self.config.notify(exc, "Qbittorrent") raise Failed(exc) diff --git a/modules/util.py b/modules/util.py index 8680b33f..5bf4652e 100755 --- a/modules/util.py +++ b/modules/util.py @@ -1,4 +1,4 @@ -""" Utility functions for qBit Manage. """ +"""Utility functions for qBit Manage.""" import json import logging @@ -12,6 +12,7 @@ import requests import ruamel.yaml from pytimeparse2 import parse +from ruamel.yaml.constructor import ConstructorError logger = logging.getLogger("qBit Manage") @@ -756,13 +757,19 @@ def human_readable_size(size, decimal_places=3): class YAML: - """Class to load and save yaml files""" + """Class to load and save yaml files with !ENV tag preservation and environment variable resolution""" def __init__(self, path=None, input_data=None, check_empty=False, create=False): self.path = path self.input_data = input_data self.yaml = ruamel.yaml.YAML() self.yaml.indent(mapping=2, sequence=2) + + # Add constructor for !ENV tag + self.yaml.Constructor.add_constructor("!ENV", self._env_constructor) + # Add representer for !ENV tag + self.yaml.Representer.add_representer(EnvStr, self._env_representer) + try: if input_data: self.data = self.yaml.load(input_data) @@ -784,8 +791,36 @@ def __init__(self, path=None, input_data=None, check_empty=False, create=False): raise Failed("YAML Error: File is empty") self.data = {} + def _env_constructor(self, loader, node): + """Constructor for !ENV tag""" + value = loader.construct_scalar(node) + # Resolve the environment variable at runtime + env_value = os.getenv(value) + if env_value is None: + raise ConstructorError(f"Environment variable '{value}' not found") + # Return a custom string subclass that preserves the !ENV tag + return EnvStr(value, env_value) + + def _env_representer(self, dumper, data): + """Representer for EnvStr class""" + return dumper.represent_scalar("!ENV", data.env_var) + def save(self): - """Save yaml file""" + """Save yaml file with !ENV tags preserved""" if self.path: - with open(self.path, "w") as filepath: + with open(self.path, "w", encoding="utf-8") as filepath: self.yaml.dump(self.data, filepath) + + +class EnvStr(str): + """Custom string subclass to preserve !ENV tags""" + + def __new__(cls, env_var, resolved_value): + # Create a new string instance with the resolved value + instance = super().__new__(cls, resolved_value) + instance.env_var = env_var # Store the environment variable name + return instance + + def __repr__(self): + """Return the resolved value as a string""" + return super().__repr__()