Merge pull request #588 from gcarq/feature/enhance-strategy-resolving-2
Add --strategy-path parameter and simplify StrategyResolver
This commit is contained in:
commit
131dfaf263
@ -48,5 +48,7 @@
|
||||
"initial_state": "running",
|
||||
"internals": {
|
||||
"process_throttle_secs": 5
|
||||
}
|
||||
},
|
||||
"strategy": "DefaultStrategy",
|
||||
"strategy_path": "/some/folder/"
|
||||
}
|
||||
|
@ -42,6 +42,13 @@ You can test it with the parameter: `--strategy TestStrategy`
|
||||
python3 ./freqtrade/main.py --strategy AwesomeStrategy
|
||||
```
|
||||
|
||||
### Specify custom strategy location
|
||||
If you want to use a strategy from a different folder you can pass `--strategy-path`
|
||||
|
||||
```bash
|
||||
python3 ./freqtrade/main.py --strategy AwesomeStrategy --strategy-path /some/folder
|
||||
```
|
||||
|
||||
**For the following section we will use the [user_data/strategies/test_strategy.py](https://github.com/gcarq/freqtrade/blob/develop/user_data/strategies/test_strategy.py)
|
||||
file as reference.**
|
||||
|
||||
|
@ -28,6 +28,7 @@ optional arguments:
|
||||
specify configuration file (default: config.json)
|
||||
-s NAME, --strategy NAME
|
||||
specify strategy class name (default: DefaultStrategy)
|
||||
--strategy-path PATH specify additional strategy lookup path
|
||||
--dry-run-db Force dry run to use a local DB
|
||||
"tradesv3.dry_run.sqlite" instead of memory DB. Work
|
||||
only if dry_run is enabled.
|
||||
@ -67,9 +68,16 @@ message the reason (File not found, or errors in your code).
|
||||
|
||||
Learn more about strategy file in [optimize your bot](https://github.com/gcarq/freqtrade/blob/develop/docs/bot-optimization.md).
|
||||
|
||||
### How to use --strategy-path?
|
||||
This parameter allows you to add an additional strategy lookup path, which gets
|
||||
checked before the default locations (The passed path must be a folder!):
|
||||
```bash
|
||||
python3 ./freqtrade/main.py --strategy AwesomeStrategy --strategy-path /some/folder
|
||||
```
|
||||
|
||||
#### How to install a strategy?
|
||||
This is very simple. Copy paste your strategy file into the folder
|
||||
`user_data/strategies`. And voila, the bot is ready to use it.
|
||||
`user_data/strategies` or use `--strategy-path`. And voila, the bot is ready to use it.
|
||||
|
||||
### How to use --dynamic-whitelist?
|
||||
Per default `--dynamic-whitelist` will retrieve the 20 currencies based
|
||||
|
@ -35,6 +35,8 @@ The table below will list all configuration parameters.
|
||||
| `telegram.token` | token | No | Your Telegram bot token. Only required if `telegram.enabled` is `true`.
|
||||
| `telegram.chat_id` | chat_id | No | Your personal Telegram account id. Only required if `telegram.enabled` is `true`.
|
||||
| `initial_state` | running | No | Defines the initial application state. More information below.
|
||||
| `strategy` | DefaultStrategy | No | Defines Strategy class to use.
|
||||
| `strategy_path` | null | No | Adds an additional strategy lookup path (must be a folder).
|
||||
| `internals.process_throttle_secs` | 5 | Yes | Set the process throttle. Value in second.
|
||||
|
||||
The definition of each config parameters is in
|
||||
|
@ -36,7 +36,7 @@ class Analyze(object):
|
||||
:param config: Bot configuration (use the one from Configuration())
|
||||
"""
|
||||
self.config = config
|
||||
self.strategy = StrategyResolver(self.config)
|
||||
self.strategy = StrategyResolver(self.config).strategy
|
||||
|
||||
@staticmethod
|
||||
def parse_ticker_dataframe(ticker: list) -> DataFrame:
|
||||
|
@ -86,6 +86,13 @@ class Arguments(object):
|
||||
type=str,
|
||||
metavar='NAME',
|
||||
)
|
||||
self.parser.add_argument(
|
||||
'--strategy-path',
|
||||
help='specify additional strategy lookup path',
|
||||
dest='strategy_path',
|
||||
type=str,
|
||||
metavar='PATH',
|
||||
)
|
||||
self.parser.add_argument(
|
||||
'--dynamic-whitelist',
|
||||
help='dynamically generate and update whitelist \
|
||||
|
@ -33,8 +33,12 @@ class Configuration(object):
|
||||
logger.info('Using config: %s ...', self.args.config)
|
||||
config = self._load_config_file(self.args.config)
|
||||
|
||||
# Add the strategy file to use
|
||||
config.update({'strategy': self.args.strategy})
|
||||
# Set strategy if not specified in config and or if it's non default
|
||||
if self.args.strategy != Constants.DEFAULT_STRATEGY or not config.get('strategy'):
|
||||
config.update({'strategy': self.args.strategy})
|
||||
|
||||
if self.args.strategy_path:
|
||||
config.update({'strategy_path': self.args.strategy_path})
|
||||
|
||||
# Load Common configuration
|
||||
config = self._load_common_config(config)
|
||||
|
@ -3,7 +3,6 @@
|
||||
Main Freqtrade bot script.
|
||||
Read the documentation to know what cli arguments you need.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import List
|
||||
|
@ -33,7 +33,6 @@ class IStrategy(ABC):
|
||||
Based on TA indicators, populates the buy signal for the given dataframe
|
||||
:param dataframe: DataFrame
|
||||
:return: DataFrame with buy column
|
||||
:return:
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
@ -41,5 +40,5 @@ class IStrategy(ABC):
|
||||
"""
|
||||
Based on TA indicators, populates the sell signal for the given dataframe
|
||||
:param dataframe: DataFrame
|
||||
:return: DataFrame with buy column
|
||||
:return: DataFrame with sell column
|
||||
"""
|
||||
|
@ -10,8 +10,6 @@ import os
|
||||
from collections import OrderedDict
|
||||
from typing import Optional, Dict, Type
|
||||
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.constants import Constants
|
||||
from freqtrade.strategy.interface import IStrategy
|
||||
|
||||
@ -23,82 +21,74 @@ class StrategyResolver(object):
|
||||
"""
|
||||
This class contains all the logic to load custom strategy class
|
||||
"""
|
||||
|
||||
__slots__ = ['strategy']
|
||||
|
||||
def __init__(self, config: Optional[Dict] = None) -> None:
|
||||
"""
|
||||
Load the custom class from config parameter
|
||||
:param config:
|
||||
:return:
|
||||
:param config: configuration dictionary or None
|
||||
"""
|
||||
config = config or {}
|
||||
|
||||
# Verify the strategy is in the configuration, otherwise fallback to the default strategy
|
||||
if 'strategy' in config:
|
||||
strategy = config['strategy']
|
||||
else:
|
||||
strategy = Constants.DEFAULT_STRATEGY
|
||||
|
||||
# Try to load the strategy
|
||||
self._load_strategy(strategy)
|
||||
strategy_name = config.get('strategy') or Constants.DEFAULT_STRATEGY
|
||||
self.strategy = self._load_strategy(strategy_name, extra_dir=config.get('strategy_path'))
|
||||
|
||||
# Set attributes
|
||||
# Check if we need to override configuration
|
||||
if 'minimal_roi' in config:
|
||||
self.custom_strategy.minimal_roi = config['minimal_roi']
|
||||
self.strategy.minimal_roi = config['minimal_roi']
|
||||
logger.info("Override strategy \'minimal_roi\' with value in config file.")
|
||||
|
||||
if 'stoploss' in config:
|
||||
self.custom_strategy.stoploss = config['stoploss']
|
||||
self.strategy.stoploss = config['stoploss']
|
||||
logger.info(
|
||||
"Override strategy \'stoploss\' with value in config file: %s.", config['stoploss']
|
||||
)
|
||||
|
||||
if 'ticker_interval' in config:
|
||||
self.custom_strategy.ticker_interval = config['ticker_interval']
|
||||
self.strategy.ticker_interval = config['ticker_interval']
|
||||
logger.info(
|
||||
"Override strategy \'ticker_interval\' with value in config file: %s.",
|
||||
config['ticker_interval']
|
||||
)
|
||||
|
||||
# Minimal ROI designed for the strategy
|
||||
self.minimal_roi = OrderedDict(sorted(
|
||||
{int(key): value for (key, value) in self.custom_strategy.minimal_roi.items()}.items(),
|
||||
key=lambda t: t[0])) # sort after converting to number
|
||||
# Sort and apply type conversions
|
||||
self.strategy.minimal_roi = OrderedDict(sorted(
|
||||
{int(key): value for (key, value) in self.strategy.minimal_roi.items()}.items(),
|
||||
key=lambda t: t[0]))
|
||||
self.strategy.stoploss = float(self.strategy.stoploss)
|
||||
self.strategy.ticker_interval = int(self.strategy.ticker_interval)
|
||||
|
||||
# Optimal stoploss designed for the strategy
|
||||
self.stoploss = float(self.custom_strategy.stoploss)
|
||||
|
||||
self.ticker_interval = int(self.custom_strategy.ticker_interval)
|
||||
|
||||
def _load_strategy(self, strategy_name: str) -> None:
|
||||
def _load_strategy(
|
||||
self, strategy_name: str, extra_dir: Optional[str] = None) -> Optional[IStrategy]:
|
||||
"""
|
||||
Search and loads the specified strategy.
|
||||
:param strategy_name: name of the module to import
|
||||
:return: None
|
||||
:param extra_dir: additional directory to search for the given strategy
|
||||
:return: Strategy instance or None
|
||||
"""
|
||||
try:
|
||||
current_path = os.path.dirname(os.path.realpath(__file__))
|
||||
abs_paths = [
|
||||
os.path.join(current_path, '..', '..', 'user_data', 'strategies'),
|
||||
current_path,
|
||||
]
|
||||
for path in abs_paths:
|
||||
self.custom_strategy = self._search_strategy(path, strategy_name)
|
||||
if self.custom_strategy:
|
||||
logger.info('Using resolved strategy %s from \'%s\'', strategy_name, path)
|
||||
return None
|
||||
current_path = os.path.dirname(os.path.realpath(__file__))
|
||||
abs_paths = [
|
||||
os.path.join(current_path, '..', '..', 'user_data', 'strategies'),
|
||||
current_path,
|
||||
]
|
||||
|
||||
raise ImportError('not found')
|
||||
# Fallback to the default strategy
|
||||
except (ImportError, TypeError) as error:
|
||||
logger.error(
|
||||
"Impossible to load Strategy '%s'. This class does not exist"
|
||||
" or contains Python code errors",
|
||||
strategy_name
|
||||
)
|
||||
logger.error(
|
||||
"The error is:\n%s.",
|
||||
error
|
||||
)
|
||||
if extra_dir:
|
||||
# Add extra strategy directory on top of search paths
|
||||
abs_paths.insert(0, extra_dir)
|
||||
|
||||
for path in abs_paths:
|
||||
strategy = self._search_strategy(path, strategy_name)
|
||||
if strategy:
|
||||
logger.info('Using resolved strategy %s from \'%s\'', strategy_name, path)
|
||||
return strategy
|
||||
|
||||
raise ImportError(
|
||||
"Impossible to load Strategy '{}'. This class does not exist"
|
||||
" or contains Python code errors".format(strategy_name)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_valid_strategies(module_path: str, strategy_name: str) -> Optional[Type[IStrategy]]:
|
||||
@ -139,28 +129,3 @@ class StrategyResolver(object):
|
||||
if strategy:
|
||||
return strategy()
|
||||
return None
|
||||
|
||||
def populate_indicators(self, dataframe: DataFrame) -> DataFrame:
|
||||
"""
|
||||
Populate indicators that will be used in the Buy and Sell strategy
|
||||
:param dataframe: Raw data from the exchange and parsed by parse_ticker_dataframe()
|
||||
:return: a Dataframe with all mandatory indicators for the strategies
|
||||
"""
|
||||
return self.custom_strategy.populate_indicators(dataframe)
|
||||
|
||||
def populate_buy_trend(self, dataframe: DataFrame) -> DataFrame:
|
||||
"""
|
||||
Based on TA indicators, populates the buy signal for the given dataframe
|
||||
:param dataframe: DataFrame
|
||||
:return: DataFrame with buy column
|
||||
:return:
|
||||
"""
|
||||
return self.custom_strategy.populate_buy_trend(dataframe)
|
||||
|
||||
def populate_sell_trend(self, dataframe: DataFrame) -> DataFrame:
|
||||
"""
|
||||
Based on TA indicators, populates the sell signal for the given dataframe
|
||||
:param dataframe: DataFrame
|
||||
:return: DataFrame with buy column
|
||||
"""
|
||||
return self.custom_strategy.populate_sell_trend(dataframe)
|
||||
|
@ -1,9 +1,10 @@
|
||||
# pragma pylint: disable=missing-docstring, protected-access, C0103
|
||||
|
||||
import logging
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from freqtrade.strategy.interface import IStrategy
|
||||
from freqtrade.strategy.resolver import StrategyResolver
|
||||
|
||||
@ -19,46 +20,50 @@ def test_search_strategy():
|
||||
|
||||
|
||||
def test_load_strategy(result):
|
||||
resolver = StrategyResolver()
|
||||
resolver._load_strategy('TestStrategy')
|
||||
assert hasattr(resolver.strategy, 'populate_indicators')
|
||||
assert 'adx' in resolver.strategy.populate_indicators(result)
|
||||
|
||||
|
||||
def test_load_strategy_custom_directory(result):
|
||||
resolver = StrategyResolver()
|
||||
extra_dir = os.path.join('some', 'path')
|
||||
with pytest.raises(
|
||||
FileNotFoundError,
|
||||
match=r".*No such file or directory: '{}'".format(extra_dir)):
|
||||
resolver._load_strategy('TestStrategy', extra_dir)
|
||||
|
||||
assert hasattr(resolver.strategy, 'populate_indicators')
|
||||
assert 'adx' in resolver.strategy.populate_indicators(result)
|
||||
|
||||
|
||||
def test_load_not_found_strategy():
|
||||
strategy = StrategyResolver()
|
||||
|
||||
assert not hasattr(StrategyResolver, 'custom_strategy')
|
||||
strategy._load_strategy('TestStrategy')
|
||||
|
||||
assert not hasattr(StrategyResolver, 'custom_strategy')
|
||||
|
||||
assert hasattr(strategy.custom_strategy, 'populate_indicators')
|
||||
assert 'adx' in strategy.populate_indicators(result)
|
||||
|
||||
|
||||
def test_load_not_found_strategy(caplog):
|
||||
strategy = StrategyResolver()
|
||||
|
||||
assert not hasattr(StrategyResolver, 'custom_strategy')
|
||||
strategy._load_strategy('NotFoundStrategy')
|
||||
|
||||
error_msg = "Impossible to load Strategy '{}'. This class does not " \
|
||||
"exist or contains Python code errors".format('NotFoundStrategy')
|
||||
assert ('freqtrade.strategy.resolver', logging.ERROR, error_msg) in caplog.record_tuples
|
||||
with pytest.raises(ImportError,
|
||||
match=r'Impossible to load Strategy \'NotFoundStrategy\'.'
|
||||
r' This class does not exist or contains Python code errors'):
|
||||
strategy._load_strategy('NotFoundStrategy')
|
||||
|
||||
|
||||
def test_strategy(result):
|
||||
strategy = StrategyResolver({'strategy': 'DefaultStrategy'})
|
||||
resolver = StrategyResolver({'strategy': 'DefaultStrategy'})
|
||||
|
||||
assert hasattr(strategy.custom_strategy, 'minimal_roi')
|
||||
assert strategy.minimal_roi[0] == 0.04
|
||||
assert hasattr(resolver.strategy, 'minimal_roi')
|
||||
assert resolver.strategy.minimal_roi[0] == 0.04
|
||||
|
||||
assert hasattr(strategy.custom_strategy, 'stoploss')
|
||||
assert strategy.stoploss == -0.10
|
||||
assert hasattr(resolver.strategy, 'stoploss')
|
||||
assert resolver.strategy.stoploss == -0.10
|
||||
|
||||
assert hasattr(strategy.custom_strategy, 'populate_indicators')
|
||||
assert 'adx' in strategy.populate_indicators(result)
|
||||
assert hasattr(resolver.strategy, 'populate_indicators')
|
||||
assert 'adx' in resolver.strategy.populate_indicators(result)
|
||||
|
||||
assert hasattr(strategy.custom_strategy, 'populate_buy_trend')
|
||||
dataframe = strategy.populate_buy_trend(strategy.populate_indicators(result))
|
||||
assert hasattr(resolver.strategy, 'populate_buy_trend')
|
||||
dataframe = resolver.strategy.populate_buy_trend(resolver.strategy.populate_indicators(result))
|
||||
assert 'buy' in dataframe.columns
|
||||
|
||||
assert hasattr(strategy.custom_strategy, 'populate_sell_trend')
|
||||
dataframe = strategy.populate_sell_trend(strategy.populate_indicators(result))
|
||||
assert hasattr(resolver.strategy, 'populate_sell_trend')
|
||||
dataframe = resolver.strategy.populate_sell_trend(resolver.strategy.populate_indicators(result))
|
||||
assert 'sell' in dataframe.columns
|
||||
|
||||
|
||||
@ -70,10 +75,10 @@ def test_strategy_override_minimal_roi(caplog):
|
||||
"0": 0.5
|
||||
}
|
||||
}
|
||||
strategy = StrategyResolver(config)
|
||||
resolver = StrategyResolver(config)
|
||||
|
||||
assert hasattr(strategy.custom_strategy, 'minimal_roi')
|
||||
assert strategy.minimal_roi[0] == 0.5
|
||||
assert hasattr(resolver.strategy, 'minimal_roi')
|
||||
assert resolver.strategy.minimal_roi[0] == 0.5
|
||||
assert ('freqtrade.strategy.resolver',
|
||||
logging.INFO,
|
||||
'Override strategy \'minimal_roi\' with value in config file.'
|
||||
@ -86,10 +91,10 @@ def test_strategy_override_stoploss(caplog):
|
||||
'strategy': 'DefaultStrategy',
|
||||
'stoploss': -0.5
|
||||
}
|
||||
strategy = StrategyResolver(config)
|
||||
resolver = StrategyResolver(config)
|
||||
|
||||
assert hasattr(strategy.custom_strategy, 'stoploss')
|
||||
assert strategy.stoploss == -0.5
|
||||
assert hasattr(resolver.strategy, 'stoploss')
|
||||
assert resolver.strategy.stoploss == -0.5
|
||||
assert ('freqtrade.strategy.resolver',
|
||||
logging.INFO,
|
||||
'Override strategy \'stoploss\' with value in config file: -0.5.'
|
||||
@ -103,31 +108,11 @@ def test_strategy_override_ticker_interval(caplog):
|
||||
'strategy': 'DefaultStrategy',
|
||||
'ticker_interval': 60
|
||||
}
|
||||
strategy = StrategyResolver(config)
|
||||
resolver = StrategyResolver(config)
|
||||
|
||||
assert hasattr(strategy.custom_strategy, 'ticker_interval')
|
||||
assert strategy.ticker_interval == 60
|
||||
assert hasattr(resolver.strategy, 'ticker_interval')
|
||||
assert resolver.strategy.ticker_interval == 60
|
||||
assert ('freqtrade.strategy.resolver',
|
||||
logging.INFO,
|
||||
'Override strategy \'ticker_interval\' with value in config file: 60.'
|
||||
) in caplog.record_tuples
|
||||
|
||||
|
||||
def test_strategy_fallback_default_strategy():
|
||||
strategy = StrategyResolver()
|
||||
strategy.logger = logging.getLogger(__name__)
|
||||
|
||||
assert not hasattr(StrategyResolver, 'custom_strategy')
|
||||
strategy._load_strategy('../../super_duper')
|
||||
assert not hasattr(StrategyResolver, 'custom_strategy')
|
||||
|
||||
|
||||
def test_strategy_singleton():
|
||||
strategy1 = StrategyResolver({'strategy': 'DefaultStrategy'})
|
||||
|
||||
assert hasattr(strategy1.custom_strategy, 'minimal_roi')
|
||||
assert strategy1.minimal_roi[0] == 0.04
|
||||
|
||||
strategy2 = StrategyResolver()
|
||||
assert hasattr(strategy2.custom_strategy, 'minimal_roi')
|
||||
assert strategy2.minimal_roi[0] == 0.04
|
||||
|
@ -71,6 +71,26 @@ def test_parse_args_invalid() -> None:
|
||||
Arguments(['-c'], '').get_parsed_arg()
|
||||
|
||||
|
||||
def test_parse_args_strategy() -> None:
|
||||
args = Arguments(['--strategy', 'SomeStrategy'], '').get_parsed_arg()
|
||||
assert args.strategy == 'SomeStrategy'
|
||||
|
||||
|
||||
def test_parse_args_strategy_invalid() -> None:
|
||||
with pytest.raises(SystemExit, match=r'2'):
|
||||
Arguments(['--strategy'], '').get_parsed_arg()
|
||||
|
||||
|
||||
def test_parse_args_strategy_path() -> None:
|
||||
args = Arguments(['--strategy-path', '/some/path'], '').get_parsed_arg()
|
||||
assert args.strategy_path == '/some/path'
|
||||
|
||||
|
||||
def test_parse_args_strategy_path_invalid() -> None:
|
||||
with pytest.raises(SystemExit, match=r'2'):
|
||||
Arguments(['--strategy-path'], '').get_parsed_arg()
|
||||
|
||||
|
||||
def test_parse_args_dynamic_whitelist() -> None:
|
||||
args = Arguments(['--dynamic-whitelist'], '').get_parsed_arg()
|
||||
assert args.dynamic_whitelist == 20
|
||||
|
@ -98,8 +98,8 @@ def test_load_config(default_conf, mocker) -> None:
|
||||
configuration = Configuration(args)
|
||||
validated_conf = configuration.load_config()
|
||||
|
||||
assert 'strategy' in validated_conf
|
||||
assert validated_conf['strategy'] == 'DefaultStrategy'
|
||||
assert validated_conf.get('strategy') == 'DefaultStrategy'
|
||||
assert validated_conf.get('strategy_path') is None
|
||||
assert 'dynamic_whitelist' not in validated_conf
|
||||
assert 'dry_run_db' not in validated_conf
|
||||
|
||||
@ -115,19 +115,39 @@ def test_load_config_with_params(default_conf, mocker) -> None:
|
||||
args = [
|
||||
'--dynamic-whitelist', '10',
|
||||
'--strategy', 'TestStrategy',
|
||||
'--dry-run-db'
|
||||
'--strategy-path', '/some/path',
|
||||
'--dry-run-db',
|
||||
]
|
||||
args = Arguments(args, '').get_parsed_arg()
|
||||
|
||||
configuration = Configuration(args)
|
||||
validated_conf = configuration.load_config()
|
||||
|
||||
assert 'dynamic_whitelist' in validated_conf
|
||||
assert validated_conf['dynamic_whitelist'] == 10
|
||||
assert 'strategy' in validated_conf
|
||||
assert validated_conf['strategy'] == 'TestStrategy'
|
||||
assert 'dry_run_db' in validated_conf
|
||||
assert validated_conf['dry_run_db'] is True
|
||||
assert validated_conf.get('dynamic_whitelist') == 10
|
||||
assert validated_conf.get('strategy') == 'TestStrategy'
|
||||
assert validated_conf.get('strategy_path') == '/some/path'
|
||||
assert validated_conf.get('dry_run_db') is True
|
||||
|
||||
|
||||
def test_load_custom_strategy(default_conf, mocker) -> None:
|
||||
"""
|
||||
Test Configuration.load_config() without any cli params
|
||||
"""
|
||||
custom_conf = deepcopy(default_conf)
|
||||
custom_conf.update({
|
||||
'strategy': 'CustomStrategy',
|
||||
'strategy_path': '/tmp/strategies',
|
||||
})
|
||||
mocker.patch('freqtrade.configuration.open', mocker.mock_open(
|
||||
read_data=json.dumps(custom_conf)
|
||||
))
|
||||
|
||||
args = Arguments([], '').get_parsed_arg()
|
||||
configuration = Configuration(args)
|
||||
validated_conf = configuration.load_config()
|
||||
|
||||
assert validated_conf.get('strategy') == 'CustomStrategy'
|
||||
assert validated_conf.get('strategy_path') == '/tmp/strategies'
|
||||
|
||||
|
||||
def test_show_info(default_conf, mocker, caplog) -> None:
|
||||
|
Loading…
Reference in New Issue
Block a user