From: subhash kumar singh Date: Mon, 2 Aug 2021 18:53:32 +0000 (+0530) Subject: Integrate pylog with xapp-frame-py X-Git-Tag: 2.3.0~3 X-Git-Url: https://gerrit.o-ran-sc.org/r/gitweb?a=commitdiff_plain;h=e310e43fc236b16d8dc8c6796cfc368fb029c7da;p=ric-plt%2Fxapp-frame-py.git Integrate pylog with xapp-frame-py Integrate pylog (https://gerrit.o-ran-sc.org/r/admin/repos/com/pylog) with xapp-frame-py. Issue-ID: RIC-330 Signed-off-by: subhash kumar singh Change-Id: I305a7a9090d83a9b4e266760f8fd76a045aa5cc4 --- diff --git a/docs/index.rst b/docs/index.rst index 74bc561..3d2d9e6 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -16,6 +16,7 @@ xApp Python Framework rmr_api.rst alarm_api.rst rnib.rst + mdclogger.rst developer-guide.rst release-notes.rst diff --git a/docs/mdclogger.rst b/docs/mdclogger.rst new file mode 100644 index 0000000..762c57e --- /dev/null +++ b/docs/mdclogger.rst @@ -0,0 +1,128 @@ +.. +.. Copyright (c) 2019 AT&T Intellectual Property. +.. +.. Copyright (c) 2019 Nokia. +.. +.. Copyright (c) 2021 Samsung +.. +.. Licensed under the Creative Commons Attribution 4.0 International +.. +.. Public License (the "License"); you may not use this file except +.. +.. in compliance with the License. You may obtain a copy of the License at +.. +.. +.. https://creativecommons.org/licenses/by/4.0/ +.. +.. +.. Unless required by applicable law or agreed to in writing, documentation +.. +.. distributed under the License is distributed on an "AS IS" BASIS, +.. +.. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +.. +.. See the License for the specific language governing permissions and +.. +.. limitations under the License. +.. +.. This source code is part of the near-RT RIC (RAN Intelligent Controller) +.. +.. platform project (RICP). +.. + +MDCLogger +========= + + +Usage +----- + +The library can be used in as shown below. + + +.. code:: bash + + ```python + from ricappframe.logger.mdclogger import MDCLogger + my_logger = MDCLogger() + my_logger.mdclog_format_init(configmap_monitor=True) + my_logger.error("This is an error log") + ``` + +A program can create several logger instances. + +mdclog_format_init() Adds the MDC log format with HostName, PodName, ContainerName, ServiceName,PID,CallbackNotifyforLogFieldChange + +Pass configmap_monitor = False in mdclog_format_init() function to stop dynamic log level change based on configmap. + +Logging Levels +-------------- +.. code:: bash + + """Severity levels of the log messages.""" + DEBUG = 10 + INFO = 20 + WARNING = 30 + ERROR = 40 + +mdcLogger API's +--------------- + +1. Set current logging level + +.. code:: bash + + def set_level(self, level: Level): + + Keyword arguments: + level -- logging level. Log messages with lower severity will be filtered. + +2. Return the current logging level + +.. code:: bash + + def get_level(self) -> Level: + +3. Add a logger specific MDC + +.. code:: bash + + def add_mdc(self, key: str, value: Value): + + Keyword arguments: + key -- MDC key + value -- MDC value + +4. Return logger's MDC value with the given key or None + +.. code:: bash + + def get_mdc(self, key: str) -> Value: + +5. Remove logger's MDC with the given key + +.. code:: bash + + def remove_mdc(self, key: str): + +6. Remove all MDCs of the logger instance. + +.. code:: bash + + def clean_mdc(self): + + +7. Initialise Logging format: + +This api Initialzes mdclog print format using MDC Dictionary by extracting the environment variables in the calling process for “SYSTEM_NAME”, “HOST_NAME”, “SERVICE_NAME”, “CONTAINER_NAME”, “POD_NAME” & “CONFIG_MAP_NAME” mapped to HostName, ServiceName, ContainerName, Podname and Configuration-file-name of the services respectively. + + +.. code:: bash + + def mdclog_format_init(configmap_monitor=False): + + Keyword arguments: + configmap_monitor -- Enables/Disables Dynamic log level change based on configmap + -- Boolean values True/False can be passed as per requirement. + + diff --git a/ricxappframe/logger/__init__.py b/ricxappframe/logger/__init__.py new file mode 100644 index 0000000..0ec8933 --- /dev/null +++ b/ricxappframe/logger/__init__.py @@ -0,0 +1,18 @@ +# Copyright (c) 2019 AT&T Intellectual Property. +# Copyright (c) 2018-2019 Nokia. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# This source code is part of the near-RT RIC (RAN Intelligent Controller) +# platform project (RICP). +# diff --git a/ricxappframe/logger/mdclogger.py b/ricxappframe/logger/mdclogger.py new file mode 100644 index 0000000..526b065 --- /dev/null +++ b/ricxappframe/logger/mdclogger.py @@ -0,0 +1,252 @@ +# Copyright (c) 2019 AT&T Intellectual Property. +# Copyright (c) 2018-2019 Nokia. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# This source code is part of the near-RT RIC (RAN Intelligent Controller) +# platform project (RICP). +# + +"""Structured logging library with Mapped Diagnostic Context + +Outputs the log entries to standard out in structured format, json currently. +Severity based filtering. +Supports Mapped Diagnostic Context (MDC). + +Set MDC pairs are automatically added to log entries by the library. +""" +from typing import TypeVar +from enum import IntEnum +import sys +import json +import time +import os +import inotify.adapters +import threading + + +class Level(IntEnum): + """Severity levels of the log messages.""" + DEBUG = 10 + INFO = 20 + WARNING = 30 + ERROR = 40 + + +LEVEL_STRINGS = {Level.DEBUG: "DEBUG", + Level.INFO: "INFO", + Level.WARNING: "WARNING", + Level.ERROR: "ERROR"} + + +Value = TypeVar('Value', str, int) + + +class MDCLogger(): + """Initialize the mdclogging module. + Calling of the function is optional. If not called, the process name + (sys.argv[0]) is used by default. + + Keyword arguments: + name -- name of the component. The name will appear as part of the log + entries. + """ + def __init__(self, name: str = sys.argv[0], level: Level = Level.ERROR): + """Initialize a Logger instance. + + Keyword arguments: + name -- name of the component. The name will appear as part of the + log entries. + """ + self.procname = name + self.current_level = level + self.mdc = {} + + # Pass configmap_monitor = True to monitor configmap to change logs dynamically using configmap + + def mdclog_format_init(self, configmap_monitor=False): + + self.mdc = {"PID": "", "SYSTEM_NAME": "", "HOST_NAME": "", "SERVICE_NAME": "", "CONTAINER_NAME": "", "POD_NAME": ""} + self.get_env_params_values() + try: + self.filename = os.environ['CONFIG_MAP_NAME'] + self.dirname = str(self.filename[:self.filename.rindex('/')]) + self.parse_file() + + if configmap_monitor: + self.register_log_change_notify() + + except Exception as e: + print("Unable to Add Watch on ConfigMap File", e) + + def _output_log(self, log: str): + + """Output the log, currently to stdout.""" + print(log) + + def log(self, level: Level, message: str): + """Log a message. + + Logs the message with the given severity if it is equal or higher than + the current logging level. + + Keyword arguments: + level -- severity of the log message + message -- log message + """ + if level >= self.current_level: + log_entry = {} + log_entry["ts"] = int(round(time.time() * 1000)) + log_entry["crit"] = LEVEL_STRINGS[level] + log_entry["id"] = self.procname + log_entry["mdc"] = self.mdc + log_entry["msg"] = message + self._output_log(json.dumps(log_entry)) + + def error(self, message: str): + """Log an error message. Equals to log(ERROR, msg).""" + self.log(Level.ERROR, message) + + def warning(self, message: str): + """Log a warning message. Equals to log(WARNING, msg).""" + self.log(Level.WARNING, message) + + def info(self, message: str): + """Log an info message. Equals to log(INFO, msg).""" + self.log(Level.INFO, message) + + def debug(self, message: str): + """Log a debug message. Equals to log(DEBUG, msg).""" + self.log(Level.DEBUG, message) + + def set_level(self, level: Level): + """Set current logging level. + + Keyword arguments: + level -- logging level. Log messages with lower severity will be + filtered. + """ + try: + self.current_level = Level(level) + except ValueError: + pass + + def get_level(self) -> Level: + """Return the current logging level.""" + return self.current_level + + def add_mdc(self, key: str, value: Value): + """Add a logger specific MDC. + + If an MDC with the given key exists, it is replaced with the new one. + An MDC can be removed with remove_mdc() or clean_mdc(). + + Keyword arguments: + key -- MDC key + value -- MDC value + """ + self.mdc[key] = value + + def get_env_params_values(self): + + try: + self.mdc['SYSTEM_NAME'] = os.environ['SYSTEM_NAME'] + except Exception: + self.mdc['SYSTEM_NAME'] = "" + + try: + self.mdc['HOST_NAME'] = os.environ['HOST_NAME'] + except Exception: + self.mdc['HOST_NAME'] = "" + + try: + self.mdc['SERVICE_NAME'] = os.environ['SERVICE_NAME'] + except Exception: + self.mdc['SERVICE_NAME'] = "" + + try: + self.mdc['CONTAINER_NAME'] = os.environ['CONTAINER_NAME'] + except Exception: + self.mdc['CONTAINER_NAME'] = "" + + try: + self.mdc['POD_NAME'] = os.environ['POD_NAME'] + except Exception: + self.mdc['POD_NAME'] = "" + try: + self.mdc['PID'] = os.getpid() + except Exception: + self.mdc['PID'] = "" + + def update_mdc_log_level_severity(self, level): + + severity_level = Level.ERROR + + if(level == ""): + print("Invalid Log Level defined in ConfigMap") + elif((level.upper() == "ERROR") or (level.upper() == "ERR")): + severity_level = Level.ERROR + elif((level.upper() == "WARNING") or (level.upper() == "WARN")): + severity_level = Level.WARNING + elif(level.upper() == "INFO"): + severity_level = Level.INFO + elif(level.upper() == "DEBUG"): + severity_level = Level.DEBUG + + self.set_level(severity_level) + + def parse_file(self): + src = open(self.filename, 'r') + level = "" + for line in src: + if 'log-level:' in line: + level_tmp = str(line.split(':')[-1]).strip() + level = level_tmp + break + src.close() + self.update_mdc_log_level_severity(level) + + def monitor_loglevel_change_handler(self): + i = inotify.adapters.Inotify() + i.add_watch(self.dirname) + for event in i.event_gen(): + if (event is not None) and ('IN_MODIFY' in str(event[1]) or 'IN_DELETE' in str(event[1])): + self.parse_file() + + def register_log_change_notify(self): + t1 = threading.Thread(target=self.monitor_loglevel_change_handler) + t1.daemon = True + try: + t1.start() + except (KeyboardInterrupt, SystemExit): + # TODO: add cleanup handler + # cleanup_stop_thread() + sys.exit() + + def get_mdc(self, key: str) -> Value: + """Return logger's MDC value with the given key or None.""" + try: + return self.mdc[key] + except KeyError: + return None + + def remove_mdc(self, key: str): + """Remove logger's MDC with the given key.""" + try: + del self.mdc[key] + except KeyError: + pass + + def clean_mdc(self): + """Remove all MDCs of the logger instance.""" + self.mdc = {} diff --git a/setup.py b/setup.py index 82899fc..1675c4e 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ setup( author="O-RAN Software Community", description="Xapp and RMR framework for Python", url="https://gerrit.o-ran-sc.org/r/admin/repos/ric-plt/xapp-frame-py", - install_requires=["inotify_simple", "msgpack", "mdclogpy", "ricsdl>=3.0.0,<4.0.0", "requests", "protobuf"], + install_requires=["inotify_simple", "msgpack", "mdclogpy", "ricsdl>=3.0.0,<4.0.0", "requests", "protobuf", "inotify"], classifiers=[ "Development Status :: 4 - Beta", "Intended Audience :: Telecommunications Industry", diff --git a/tests/mdclogtestutils.py b/tests/mdclogtestutils.py new file mode 100644 index 0000000..c4283e8 --- /dev/null +++ b/tests/mdclogtestutils.py @@ -0,0 +1,35 @@ +# Copyright (c) 2019 AT&T Intellectual Property. +# Copyright (c) 2018-2019 Nokia. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# This source code is part of the near-RT RIC (RAN Intelligent Controller) +# platform project (RICP). +# +"""Helper functions for mdclogpy unit tests.""" + +import json + + +class TestMdcLogUtils(): + """Helper functions for unit tests.""" + + @staticmethod + def get_logs(call_args_list): + """Return the logs as a list of strings from the call_args_list.""" + return [x[0][0] for x in call_args_list] + + @staticmethod + def get_logs_as_json(call_args_list): + """Return the logs as a list of json objects from the call_args_list.""" + return list(map(json.loads, TestMdcLogUtils.get_logs(call_args_list))) diff --git a/tests/test_Logger.py b/tests/test_Logger.py new file mode 100644 index 0000000..8a1a278 --- /dev/null +++ b/tests/test_Logger.py @@ -0,0 +1,223 @@ +# Copyright (c) 2019 AT&T Intellectual Property. +# Copyright (c) 2018-2019 Nokia. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# This source code is part of the near-RT RIC (RAN Intelligent Controller) +# platform project (RICP). +# +"""Unit tests for Logger.py""" +import unittest +from unittest.mock import patch + +from ricxappframe.logger.mdclogger import Level, MDCLogger +from .mdclogtestutils import TestMdcLogUtils + + +class TestMdcLog(unittest.TestCase): + """Unit tests for mdclog.py""" + + def setUp(self): + self.logger = MDCLogger() + + def tearDown(self): + pass + + def test_that_get_level_returns_the_current_log_level(self): + + # default level is ERROR + self.assertEqual(self.logger.get_level(), Level.ERROR) + self.logger.set_level(Level.INFO) + self.assertEqual(self.logger.get_level(), Level.INFO) + self.logger.set_level(Level.WARNING) + self.assertEqual(self.logger.get_level(), Level.WARNING) + self.logger.set_level(Level.ERROR) + self.assertEqual(self.logger.get_level(), Level.ERROR) + self.logger.set_level(Level.DEBUG) + self.assertEqual(self.logger.get_level(), Level.DEBUG) + + def test_that_set_level_does_not_accept_incorrect_level(self): + + self.logger.set_level(Level.INFO) + self.logger.set_level(55) + self.assertEqual(self.logger.get_level(), Level.INFO) + + @patch('ricxappframe.logger.mdclogger.MDCLogger._output_log') + def test_that_logs_with_lower_than_current_level_(self, output_mock): + + self.logger.set_level(Level.WARNING) + self.logger.log(Level.DEBUG, "DEBUG") + self.logger.log(Level.INFO, "INFO") + self.logger.log(Level.WARNING, "WARNING") + self.logger.log(Level.ERROR, "ERROR") + + self.assertEqual(2, output_mock.call_count) + logs = TestMdcLogUtils.get_logs_as_json(output_mock.call_args_list) + self.assertEqual(logs[0]["msg"], "WARNING") + self.assertEqual(logs[1]["msg"], "ERROR") + + @patch('ricxappframe.logger.mdclogger.MDCLogger._output_log') + def test_that_logs_with_lower_than_current_level_are_not_logged(self, output_mock): + + self.logger.set_level(Level.WARNING) + self.logger.log(Level.DEBUG, "DEBUG") + self.logger.log(Level.INFO, "INFO") + self.logger.log(Level.WARNING, "WARNING") + self.logger.log(Level.ERROR, "ERROR") + + self.assertEqual(2, output_mock.call_count) + logs = TestMdcLogUtils.get_logs_as_json(output_mock.call_args_list) + self.assertEqual(logs[0]["msg"], "WARNING") + self.assertEqual(logs[1]["msg"], "ERROR") + + @patch('ricxappframe.logger.mdclogger.MDCLogger._output_log') + def test_that_log_contains_correct_criticality(self, output_mock): + + self.logger.set_level(Level.DEBUG) + + self.logger.log(Level.DEBUG, "debug test log") + self.logger.log(Level.INFO, "info test log") + self.logger.log(Level.WARNING, "warning test log") + self.logger.log(Level.ERROR, "error test log") + + self.logger.debug("another debug test log") + self.logger.info("another info test log") + self.logger.warning("another warning test log") + self.logger.error("another error test log") + + self.assertEqual(8, output_mock.call_count) + logs = TestMdcLogUtils.get_logs_as_json(output_mock.call_args_list) + self.assertEqual(logs[0]["crit"], "DEBUG") + self.assertEqual(logs[1]["crit"], "INFO") + self.assertEqual(logs[2]["crit"], "WARNING") + self.assertEqual(logs[3]["crit"], "ERROR") + self.assertEqual(logs[4]["crit"], "DEBUG") + self.assertEqual(logs[5]["crit"], "INFO") + self.assertEqual(logs[6]["crit"], "WARNING") + self.assertEqual(logs[7]["crit"], "ERROR") + + @patch('time.time') + @patch('ricxappframe.logger.mdclogger.MDCLogger._output_log') + def test_that_log_contains_correct_timestamp(self, output_mock, mock_time): + + mock_time.return_value = 1554806251.4388545 + self.logger.error("timestamp test") + + logs = TestMdcLogUtils.get_logs_as_json(output_mock.call_args_list) + self.assertEqual(logs[0]["ts"], 1554806251439) + + @patch('ricxappframe.logger.mdclogger.MDCLogger._output_log') + def test_that_log_contains_correct_message(self, output_mock): + + self.logger.error("message test") + logs = TestMdcLogUtils.get_logs_as_json(output_mock.call_args_list) + print(logs) + self.assertEqual(logs[0]["msg"], "message test") + + @patch('ricxappframe.logger.mdclogger.MDCLogger._output_log') + def test_that_log_message_is_escaped_to_valid_json_string(self, output_mock): + + self.logger.set_level(Level.DEBUG) + + self.logger.info(r'\ and "') + + logs = TestMdcLogUtils.get_logs(output_mock.call_args_list) + self.assertTrue(r'\\ and \"' in logs[0]) + logs = TestMdcLogUtils.get_logs_as_json(output_mock.call_args_list) + self.assertEqual(logs[0]["msg"], r'\ and "') + + @patch('ricxappframe.logger.mdclogger.MDCLogger._output_log') + def test_that_empty_mdc_is_logged_correctly(self, output_mock): + self.logger.mdclog_format_init(configmap_monitor=True) + self.logger.error("empty mdc test") + self.logger.error(output_mock.call_args_list) + logs = TestMdcLogUtils.get_logs_as_json(output_mock.call_args_list) + self.assertEqual(logs[0]["msg"], 'empty mdc test') + + @patch('ricxappframe.logger.mdclogger.MDCLogger._output_log') + def test_that_config_map_is_monitored_correctly(self, output_mock): + src = open("//tmp//log", "w") + src.write("log-level: debug\n") + src.close() + self.logger.filename = "/tmp/log" + self.logger.dirname = "/tmp/" + self.logger.mdc = {"PID": "", "SYSTEM_NAME": "", "HOST_NAME": "", "SERVICE_NAME": "", "CONTAINER_NAME": "", "POD_NAME": ""} + self.logger.get_env_params_values() + self.logger.parse_file() + self.logger.error("Hello") + self.assertEqual(self.logger.get_level(), Level.DEBUG) + + @patch('ricxappframe.logger.mdclogger.MDCLogger._output_log') + def test_that_mdc_values_are_logged_correctly(self, output_mock): + + self.logger.add_mdc("key1", "value1") + self.logger.add_mdc("key2", "value2") + self.logger.error("mdc test") + + logs = TestMdcLogUtils.get_logs_as_json(output_mock.call_args_list) + self.assertEqual(logs[0]["mdc"]["key1"], "value1") + self.assertEqual(logs[0]["mdc"]["key2"], "value2") + + @patch('ricxappframe.logger.mdclogger.MDCLogger._output_log') + def test_that_mdc_pid_logged_correctly(self, output_mock): + self.logger.mdclog_format_init(configmap_monitor=True) + self.logger.error("mdc test") + logs = TestMdcLogUtils.get_logs_as_json(output_mock.call_args_list) + self.assertTrue(logs[0]["mdc"]["PID"]) + + def test_that_mdc_values_can_be_added_and_removed(self): + + self.logger.add_mdc("key1", "value1") + self.logger.add_mdc("key2", "value2") + self.assertEqual(self.logger.get_mdc("key2"), "value2") + self.assertEqual(self.logger.get_mdc("key1"), "value1") + self.assertEqual(self.logger.get_mdc("non_existent"), None) + self.logger.remove_mdc("key1") + self.assertEqual(self.logger.get_mdc("key1"), None) + self.logger.remove_mdc("non_existent") + self.logger.clean_mdc() + self.assertEqual(self.logger.get_mdc("key2"), None) + + @patch('ricxappframe.logger.mdclogger.MDCLogger._output_log') + def test_multiple_logger_instances(self, output_mock): + + logger1 = MDCLogger("logger1") + logger2 = MDCLogger("logger2") + logger1.add_mdc("logger1_key1", "logger1_value1") + logger1.add_mdc("logger1_key2", "logger1_value2") + logger2.add_mdc("logger2_key1", "logger2_value1") + logger2.add_mdc("logger2_key2", "logger2_value2") + + logger1.error("error msg") + logger2.error("warning msg") + + logs = TestMdcLogUtils.get_logs_as_json(output_mock.call_args_list) + self.assertEqual(2, output_mock.call_count) + + self.assertEqual(logs[0]["id"], "logger1") + self.assertEqual(logs[0]["crit"], "ERROR") + self.assertEqual(logs[0]["msg"], "error msg") + self.assertEqual(logs[0]["mdc"]["logger1_key1"], "logger1_value1") + self.assertEqual(logs[0]["mdc"]["logger1_key2"], "logger1_value2") + self.assertEqual(len(logs[0]["mdc"]), 2) + + self.assertEqual(logs[1]["id"], "logger2") + self.assertEqual(logs[1]["crit"], "ERROR") + self.assertEqual(logs[1]["msg"], "warning msg") + self.assertEqual(logs[1]["mdc"]["logger2_key1"], "logger2_value1") + self.assertEqual(logs[1]["mdc"]["logger2_key2"], "logger2_value2") + self.assertEqual(len(logs[1]["mdc"]), 2) + + +if __name__ == '__main__': + unittest.main() diff --git a/tox.ini b/tox.ini index 0cdf5e6..0180e50 100644 --- a/tox.ini +++ b/tox.ini @@ -67,6 +67,7 @@ deps = sphinx msgpack ricsdl protobuf + inotify commands = sphinx-build -W -b html -n -d {envtmpdir}/doctrees ./docs/ {toxinidir}/docs/_build/html echo "Generated docs available in {toxinidir}/docs/_build/html"