From c9523d93422ae1dd8dfa9889d7e4b83538c2fc8e Mon Sep 17 00:00:00 2001 From: "Jason M. Gates" Date: Thu, 24 Mar 2022 07:51:47 -0600 Subject: [PATCH 01/11] Add YAPF job --- .gitlab-ci.yml | 23 ++++++++++++++++++++++- .style.yapf | 18 ++++++++++++++++++ tests/requirements.txt | 1 + 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 .style.yapf diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8b5b091..1585d92 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -146,7 +146,7 @@ pytest: junit: tests/coverage.xml -# Ensure we adhere to PEP8. +# Ensure we pass the `flake8` linter. flake8: stage: test timeout: 5m @@ -157,6 +157,27 @@ flake8: --exclude=shelllogger-env +# Ensure we adhere to our style guide.variables: +yapf: + stage: test + timeout: 5m + cache: + <<: *global_cache + script: + - python3 -m yapf + --diff + --recursive + . > yapf.diff.txt + - |- + if [[ "$(wc -l yapf.diff.txt | awk '{print $1}')" != "0" ]]; then + echo "----------" + echo "Your code does not match our Python style guide." + echo "Run 'yapf -i' on the following files:" + grep "^+++.*(reformatted)$" yapf.diff.txt | awk '{print $2}' + exit 1 + fi + + # Ensure the examples run without problems. examples: stage: test diff --git a/.style.yapf b/.style.yapf new file mode 100644 index 0000000..4e39ab2 --- /dev/null +++ b/.style.yapf @@ -0,0 +1,18 @@ +# YAPF Style Configuration + +# See: /~https://github.com/google/yapf#knobs for available options + +[style] +# Base style +based_on_style = pep8 + +# Customizations +arithmetic_precedence_indication = True +blank_line_before_nested_class_or_def = True +coalesce_brackets = True +dedent_closing_brackets = True +join_multiple_lines = False +split_all_comma_separated_values = True +split_before_arithmetic_operator = True +split_before_dot = True +split_complex_comprehension = True diff --git a/tests/requirements.txt b/tests/requirements.txt index e151467..773a547 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -6,3 +6,4 @@ psutil pytest >= 6.2 pytest-cov >= 2.12 pytest-mock >= 3.6 +yapf From 63a89f6dbf16d58c9e3094fda2da177e154885a8 Mon Sep 17 00:00:00 2001 From: "Jason M. Gates" Date: Thu, 24 Mar 2022 08:02:27 -0600 Subject: [PATCH 02/11] Debugging --- .gitlab-ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1585d92..60b1730 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -167,7 +167,11 @@ yapf: - python3 -m yapf --diff --recursive - . > yapf.diff.txt + --style .style.yapf + examples/ + src/ + tests/ + > yapf.diff.txt - |- if [[ "$(wc -l yapf.diff.txt | awk '{print $1}')" != "0" ]]; then echo "----------" From 156a3e39c7fde670b51c4249ad1f3caeebbe6528 Mon Sep 17 00:00:00 2001 From: "Jason M. Gates" Date: Thu, 24 Mar 2022 08:02:52 -0600 Subject: [PATCH 03/11] Tweak --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 60b1730..2f5cea3 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -165,9 +165,9 @@ yapf: <<: *global_cache script: - python3 -m yapf + --style .style.yapf --diff --recursive - --style .style.yapf examples/ src/ tests/ From c478aa5078dbdbdeba08d18836c1474772cdf6cf Mon Sep 17 00:00:00 2001 From: "Jason M. Gates" Date: Thu, 24 Mar 2022 08:12:34 -0600 Subject: [PATCH 04/11] Debugging --- tests/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/requirements.txt b/tests/requirements.txt index 773a547..7029610 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -6,4 +6,5 @@ psutil pytest >= 6.2 pytest-cov >= 2.12 pytest-mock >= 3.6 +toml yapf From ca6b5ae21536432c47c14b068359b1daef390837 Mon Sep 17 00:00:00 2001 From: "Jason M. Gates" Date: Thu, 24 Mar 2022 08:20:56 -0600 Subject: [PATCH 05/11] Archive yapf.diff.txt --- .gitlab-ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2f5cea3..9264765 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -164,6 +164,7 @@ yapf: cache: <<: *global_cache script: + - set +e - python3 -m yapf --style .style.yapf --diff @@ -180,6 +181,11 @@ yapf: grep "^+++.*(reformatted)$" yapf.diff.txt | awk '{print $2}' exit 1 fi + artifacts: + expire_in: 1 week + paths: + - yapf.diff.txt + when: on_failure # Ensure the examples run without problems. From e298c35ae8acd4a6cafa8bd7689f19371a5afd9d Mon Sep 17 00:00:00 2001 From: "Jason M. Gates" Date: Thu, 24 Mar 2022 08:23:52 -0600 Subject: [PATCH 06/11] Alphabetize job specs --- .gitlab-ci.yml | 94 +++++++++++++++++++++++++------------------------- 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9264765..8e78f94 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -56,8 +56,6 @@ after_script: # Create the virtual environment, install the requirements, and save the # environment to the cache. requirements: - stage: prepare - timeout: 10m before_script: - conda create --prefix ./shelllogger-env --yes python=3.7 pip cache: @@ -79,6 +77,8 @@ requirements: # Show the contents of the virtual environment. - conda list + stage: prepare + timeout: 10m #----------------------------------------------------------------------- @@ -88,24 +88,22 @@ requirements: # Test building a distribution. wheel: - stage: install - timeout: 5m - cache: - <<: *global_cache - policy: pull - script: - - pip wheel --no-deps -w dist . artifacts: name: "shelllogger-dist" paths: - dist/shelllogger*.whl expire_in: 6 weeks + cache: + <<: *global_cache + policy: pull + script: + - pip wheel --no-deps -w dist . + stage: install + timeout: 5m # Test installation of the package. install: - stage: install - timeout: 5m cache: <<: *global_cache script: @@ -115,6 +113,8 @@ install: # Show the contents of the virtual environment to ensure it's there. - conda list + stage: install + timeout: 5m #----------------------------------------------------------------------- @@ -124,10 +124,15 @@ install: # Execute the unit tests. pytest: - stage: test - timeout: 5m + artifacts: + paths: + - tests/htmlcov + reports: + cobertura: tests/coverage.xml + junit: tests/coverage.xml cache: <<: *global_cache + coverage: '/TOTAL\s*[0-9]*\s*[0-9]*\s*[0-9]*\s*[0-9]*\s*(\d+%)/' script: - python3 -m pytest --cov=src.shelllogger @@ -137,30 +142,28 @@ pytest: --cov-report=xml --full-trace tests/ - coverage: '/TOTAL\s*[0-9]*\s*[0-9]*\s*[0-9]*\s*[0-9]*\s*(\d+%)/' - artifacts: - paths: - - tests/htmlcov - reports: - cobertura: tests/coverage.xml - junit: tests/coverage.xml + stage: test + timeout: 5m # Ensure we pass the `flake8` linter. flake8: - stage: test - timeout: 5m cache: <<: *global_cache script: - python3 -m flake8 --exclude=shelllogger-env + stage: test + timeout: 5m # Ensure we adhere to our style guide.variables: yapf: - stage: test - timeout: 5m + artifacts: + expire_in: 1 week + paths: + - yapf.diff.txt + when: on_failure cache: <<: *global_cache script: @@ -181,17 +184,16 @@ yapf: grep "^+++.*(reformatted)$" yapf.diff.txt | awk '{print $2}' exit 1 fi - artifacts: - expire_in: 1 week - paths: - - yapf.diff.txt - when: on_failure + stage: test + timeout: 5m # Ensure the examples run without problems. examples: - stage: test - timeout: 5m + artifacts: + paths: + - examples/log* + - examples/*.html cache: <<: *global_cache policy: pull @@ -206,10 +208,8 @@ examples: - cp -L log_hello_world_html_with_stats/Hello_World_HTML_with_Stats.html . - python3 ./build_flex.py - cp -L log_build_flex/Build_Flex.html . - artifacts: - paths: - - examples/log* - - examples/*.html + stage: test + timeout: 5m #----------------------------------------------------------------------- @@ -219,16 +219,16 @@ examples: # Generate the documentation. sphinx: - stage: documentation - timeout: 5m + artifacts: + paths: + - doc/html cache: <<: *global_cache script: - cd doc - bash make_html_docs.sh - artifacts: - paths: - - doc/html + stage: documentation + timeout: 5m #----------------------------------------------------------------------- @@ -238,8 +238,9 @@ sphinx: # Publish coverage data and documentation (if on the main branch). pages: - stage: deploy - timeout: 5m + artifacts: + paths: + - public cache: <<: *global_cache policy: pull @@ -250,9 +251,8 @@ pages: - mv tests/htmlcov public/. - mv doc/html/* public/. - mv examples/*.html public/examples/. - artifacts: - paths: - - public + stage: deploy + timeout: 5m #----------------------------------------------------------------------- @@ -262,8 +262,6 @@ pages: # Test that uninstalling from a virtual environment works. uninstall: - stage: finish - timeout: 5m cache: <<: *global_cache script: @@ -271,3 +269,5 @@ uninstall: # Show the contents of the virtual environment. - conda list + stage: finish + timeout: 5m From cf7752862ff604346fac78aee15eddebc8c423a2 Mon Sep 17 00:00:00 2001 From: "Jason M. Gates" Date: Thu, 24 Mar 2022 08:24:57 -0600 Subject: [PATCH 07/11] Alphabetize jobs --- .gitlab-ci.yml | 70 +++++++++++++++++++++++++------------------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8e78f94..24ed5a7 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -122,6 +122,41 @@ install: #----------------------------------------------------------------------- +# Ensure the examples run without problems. +examples: + artifacts: + paths: + - examples/log* + - examples/*.html + cache: + <<: *global_cache + policy: pull + script: + - cd examples + - python3 ./hello_world_html.py + - cp -L log_hello_world_html/Hello_World_HTML.html . + - python3 ./hello_world_html_and_console.py + - cp -L log_hello_world_html_and_console/Hello_World_HTML_and_Console.html + . + - python3 ./hello_world_html_with_stats.py + - cp -L log_hello_world_html_with_stats/Hello_World_HTML_with_Stats.html . + - python3 ./build_flex.py + - cp -L log_build_flex/Build_Flex.html . + stage: test + timeout: 5m + + +# Ensure we pass the `flake8` linter. +flake8: + cache: + <<: *global_cache + script: + - python3 -m flake8 + --exclude=shelllogger-env + stage: test + timeout: 5m + + # Execute the unit tests. pytest: artifacts: @@ -146,17 +181,6 @@ pytest: timeout: 5m -# Ensure we pass the `flake8` linter. -flake8: - cache: - <<: *global_cache - script: - - python3 -m flake8 - --exclude=shelllogger-env - stage: test - timeout: 5m - - # Ensure we adhere to our style guide.variables: yapf: artifacts: @@ -188,30 +212,6 @@ yapf: timeout: 5m -# Ensure the examples run without problems. -examples: - artifacts: - paths: - - examples/log* - - examples/*.html - cache: - <<: *global_cache - policy: pull - script: - - cd examples - - python3 ./hello_world_html.py - - cp -L log_hello_world_html/Hello_World_HTML.html . - - python3 ./hello_world_html_and_console.py - - cp -L log_hello_world_html_and_console/Hello_World_HTML_and_Console.html - . - - python3 ./hello_world_html_with_stats.py - - cp -L log_hello_world_html_with_stats/Hello_World_HTML_with_Stats.html . - - python3 ./build_flex.py - - cp -L log_build_flex/Build_Flex.html . - stage: test - timeout: 5m - - #----------------------------------------------------------------------- # Stage: Documentation #----------------------------------------------------------------------- From 55dd0e187030e95250841d493c088d0bd06a7fde Mon Sep 17 00:00:00 2001 From: "Jason M. Gates" Date: Thu, 24 Mar 2022 08:36:12 -0600 Subject: [PATCH 08/11] Update YAPF error message --- .gitlab-ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 24ed5a7..3ae1c34 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -204,7 +204,9 @@ yapf: if [[ "$(wc -l yapf.diff.txt | awk '{print $1}')" != "0" ]]; then echo "----------" echo "Your code does not match our Python style guide." - echo "Run 'yapf -i' on the following files:" + echo -n "Run 'yapf --style .style.yapf --in-place --recursive " + echo "examples/ src/ tests/' in the repository root to fix it." + echo "The offending files are:" grep "^+++.*(reformatted)$" yapf.diff.txt | awk '{print $2}' exit 1 fi From 11f1dc3586182aa426f0007d9836e118ee1bfc34 Mon Sep 17 00:00:00 2001 From: "Jason M. Gates" Date: Thu, 24 Mar 2022 09:22:05 -0600 Subject: [PATCH 09/11] Fix style issues --- examples/build_flex.py | 64 +++-- examples/hello_world_html.py | 16 +- examples/hello_world_html_and_console.py | 29 +- examples/hello_world_html_with_stats.py | 29 +- src/shelllogger/html_utilities.py | 329 +++++++++++++---------- src/shelllogger/shell.py | 119 ++++---- src/shelllogger/shell_logger.py | 266 ++++++++++-------- src/shelllogger/stats_collector.py | 17 +- tests/test_shell_logger.py | 217 +++++++++------ 9 files changed, 667 insertions(+), 419 deletions(-) diff --git a/examples/build_flex.py b/examples/build_flex.py index cd06b03..0377859 100755 --- a/examples/build_flex.py +++ b/examples/build_flex.py @@ -3,24 +3,50 @@ from pathlib import Path from shelllogger import ShellLogger -sl = ShellLogger("Build Flex", - Path.cwd() / f"log_{Path(__file__).stem}") -sl.print("This example demonstrates cloning, configuring, and building the " - "Flex tool.") -sl.log("Clone the Flex repository.", - "git clone --depth 1 --branch flex-2.5.39 " - "/~https://github.com/westes/flex.git flex-2.5.39", live_stdout=True, - live_stderr=True) -sl.log("Run `autogen`.", "./autogen.sh", cwd=Path.cwd()/"flex-2.5.39", - live_stdout=True, live_stderr=True) -sl.log("Configure flex.", "./configure --prefix=$(dirname $(pwd))/flex", - cwd=Path.cwd()/"flex-2.5.39", live_stdout=True, live_stderr=True, - measure=["cpu", "memory", "disk"]) -sl.log("Build `libcompat.la`.", "make libcompat.la", - cwd=Path.cwd()/"flex-2.5.39/lib", live_stdout=True, live_stderr=True, - measure=["cpu", "memory", "disk"]) -sl.log("Build & install flex.", "make install-exec", - cwd=Path.cwd()/"flex-2.5.39", live_stdout=True, live_stderr=True, - measure=["cpu", "memory", "disk"]) +sl = ShellLogger("Build Flex", Path.cwd() / f"log_{Path(__file__).stem}") +sl.print( + "This example demonstrates cloning, configuring, and building the Flex " + "tool." +) +FLEX_VERSION = "flex-2.5.39" +sl.log( + "Clone the Flex repository.", + f"git clone --depth 1 --branch {FLEX_VERSION} " + f"/~https://github.com/westes/flex.git {FLEX_VERSION}", + live_stdout=True, + live_stderr=True +) +sl.log( + "Run `autogen`.", + "./autogen.sh", + cwd=Path.cwd() / FLEX_VERSION, + live_stdout=True, + live_stderr=True +) +measure = ["cpu", "memory", "disk"] +sl.log( + "Configure flex.", + "./configure --prefix=$(dirname $(pwd))/flex", + cwd=Path.cwd() / FLEX_VERSION, + live_stdout=True, + live_stderr=True, + measure=measure +) +sl.log( + "Build `libcompat.la`.", + "make libcompat.la", + cwd=Path.cwd() / f"{FLEX_VERSION}/lib", + live_stdout=True, + live_stderr=True, + measure=measure +) +sl.log( + "Build & install flex.", + "make install-exec", + cwd=Path.cwd() / FLEX_VERSION, + live_stdout=True, + live_stderr=True, + measure=measure +) sl.finalize() print(f"Open {sl.html_file} to view the log.") diff --git a/examples/hello_world_html.py b/examples/hello_world_html.py index 3b7ae8a..50ba008 100755 --- a/examples/hello_world_html.py +++ b/examples/hello_world_html.py @@ -3,12 +3,16 @@ from pathlib import Path from shelllogger import ShellLogger -sl = ShellLogger("Hello World HTML", - Path.cwd() / f"log_{Path(__file__).stem}") -sl.print("This example demonstrates logging information solely to the HTML " - "log file.") +sl = ShellLogger("Hello World HTML", Path.cwd() / f"log_{Path(__file__).stem}") +sl.print( + "This example demonstrates logging information solely to the HTML log " + "file." +) sl.log("Greet everyone to make them feel welcome.", "echo 'Hello World'") -sl.log("Tell everyone who you are, but from a different directory.", "whoami", - cwd=Path.cwd().parent) +sl.log( + "Tell everyone who you are, but from a different directory.", + "whoami", + cwd=Path.cwd().parent +) sl.finalize() print(f"Open {sl.html_file} to view the log.") diff --git a/examples/hello_world_html_and_console.py b/examples/hello_world_html_and_console.py index aa023e5..8a1daa1 100755 --- a/examples/hello_world_html_and_console.py +++ b/examples/hello_world_html_and_console.py @@ -3,13 +3,26 @@ from pathlib import Path from shelllogger import ShellLogger -sl = ShellLogger("Hello World HTML and Console", - Path.cwd() / f"log_{Path(__file__).stem}") -sl.print("This example demonstrates logging information both to the HTML log " - "file and to the console simultaneously.") -sl.log("Greet everyone to make them feel welcome.", "echo 'Hello World'", - live_stdout=True, live_stderr=True) -sl.log("Tell everyone who you are, but from a different directory.", "whoami", - cwd=Path.cwd().parent, live_stdout=True, live_stderr=True) +sl = ShellLogger( + "Hello World HTML and Console", + Path.cwd() / f"log_{Path(__file__).stem}" +) +sl.print( + "This example demonstrates logging information both to the HTML log file " + "and to the console simultaneously." +) +sl.log( + "Greet everyone to make them feel welcome.", + "echo 'Hello World'", + live_stdout=True, + live_stderr=True +) +sl.log( + "Tell everyone who you are, but from a different directory.", + "whoami", + cwd=Path.cwd().parent, + live_stdout=True, + live_stderr=True +) sl.finalize() print(f"Open {sl.html_file} to view the log.") diff --git a/examples/hello_world_html_with_stats.py b/examples/hello_world_html_with_stats.py index a7e70f1..d1bca42 100755 --- a/examples/hello_world_html_with_stats.py +++ b/examples/hello_world_html_with_stats.py @@ -3,14 +3,25 @@ from pathlib import Path from shelllogger import ShellLogger -sl = ShellLogger("Hello World HTML with Stats", - Path.cwd() / f"log_{Path(__file__).stem}") -sl.print("This example demonstrates logging information solely to the HTML " - "log file, while collecting CPU, memory, and disk statistics at the " - "same time.") -sl.log("Greet everyone to make them feel welcome.", "echo 'Hello World'", - measure=["cpu", "memory", "disk"]) -sl.log("Tell everyone who you are, but from a different directory.", "whoami", - cwd=Path.cwd().parent, measure=["cpu", "memory", "disk"]) +sl = ShellLogger( + "Hello World HTML with Stats", + Path.cwd() / f"log_{Path(__file__).stem}" +) +sl.print( + "This example demonstrates logging information solely to the HTML log " + "file, while collecting CPU, memory, and disk statistics at the same time." +) +measure = ["cpu", "memory", "disk"] +sl.log( + "Greet everyone to make them feel welcome.", + "echo 'Hello World'", + measure=measure +) +sl.log( + "Tell everyone who you are, but from a different directory.", + "whoami", + cwd=Path.cwd().parent, + measure=measure +) sl.finalize() print(f"Open {sl.html_file} to view the log.") diff --git a/src/shelllogger/html_utilities.py b/src/shelllogger/html_utilities.py index 4d8b99f..080ab3f 100644 --- a/src/shelllogger/html_utilities.py +++ b/src/shelllogger/html_utilities.py @@ -10,8 +10,8 @@ def nested_simplenamespace_to_dict( - namespace: Union[str, bytes, tuple, Mapping, Iterable, SimpleNamespace] -) -> Union[str, bytes, tuple, dict, list]: + namespace: Union[str, bytes, tuple, Mapping, Iterable, SimpleNamespace] +) -> Union[str, bytes, tuple, dict, list]: # yapf: disable """ Convert a ``SimpleNamespace``, which may include nested namespaces, iterables, and mappings, to a ``dict`` containing the equivalent @@ -31,8 +31,10 @@ def nested_simplenamespace_to_dict( elif isinstance(namespace, (str, bytes, tuple)): return namespace elif isinstance(namespace, Mapping): - return {k: nested_simplenamespace_to_dict(v) for k, v in - namespace.items()} + return { + k: nested_simplenamespace_to_dict(v) + for k, v in namespace.items() + } # yapf: disable elif isinstance(namespace, Iterable): return [nested_simplenamespace_to_dict(x) for x in namespace] elif isinstance(namespace, SimpleNamespace): @@ -51,9 +53,8 @@ def get_human_time(milliseconds: float) -> str: Returns: A string representation of the date and time. """ - return datetime.fromtimestamp(milliseconds / 1000.0).strftime( - '%Y-%m-%d %H:%M:%S.%f' - ) + seconds = milliseconds / 1000.0 + return datetime.fromtimestamp(seconds).strftime('%Y-%m-%d %H:%M:%S.%f') def opening_html_text() -> str: @@ -64,9 +65,7 @@ def opening_html_text() -> str: A string containing the first line of the HTML document through ````. """ - return ("" - + "" - + html_header()) + return "" + html_header() def closing_html_text() -> str: @@ -89,9 +88,9 @@ def append_html(*args: Union[str, Iterator[str]], output: Path) -> None: """ def _append_html( - f: TextIO, - *inner_args: Union[str, bytes, Iterable] - ) -> None: + f: TextIO, + *inner_args: Union[str, bytes, Iterable] + ) -> None: # yapf: disable """ Write some text to the given HTML log file. @@ -149,9 +148,9 @@ def flatten(element: Union[str, bytes, Iterable]) -> Iterator[str]: def parent_logger_card_html( - name: str, - *args: List[Iterator[str]] -) -> Iterator[str]: + name: str, + *args: List[Iterator[str]] +) -> Iterator[str]: # yapf: disable """ Generate the HTML for the card corresponding to the parent :class:`ShellLogger`. The HTML elements are yielded one at a time @@ -201,10 +200,10 @@ def child_logger_card(log) -> Iterator[str]: def child_logger_card_html( - name: str, - duration: str, - *args: Union[Iterator[str], List[Iterator[str]]] -) -> Iterator[str]: + name: str, + duration: str, + *args: Union[Iterator[str], List[Iterator[str]]] +) -> Iterator[str]: # yapf: disable """ Generate the HTML for a card corresponding to the child :class:`ShellLogger`. The HTML elements are yielded one at a time @@ -240,9 +239,9 @@ def child_logger_card_html( def command_card_html( - log: dict, - *args: Iterator[Union[str, Iterable]] -) -> Iterator[str]: + log: dict, + *args: Iterator[Union[str, Iterable]] +) -> Iterator[str]: # yapf: disable """ Generate the HTML for a card corresponding to a command that was run. The HTML elements are yielded one at a time to avoid loading @@ -295,11 +294,13 @@ def html_message_card(log: dict) -> Iterator[str]: .replace(':', '-') .replace('/', '_') .replace('.', '-') + ) # yapf: disable + header, indent, footer = split_template( + html_message_template, + "message", + title=log["msg_title"], + timestamp=timestamp ) - header, indent, footer = split_template(html_message_template, - "message", - title=log["msg_title"], - timestamp=timestamp) text = html_encode(log["msg"]) text = "
" + text.replace('\n', "
") + "
" yield header @@ -354,10 +355,10 @@ def command_detail_list(cmd_id: str, *args: Iterator[str]) -> Iterator[str]: def command_detail( - cmd_id: str, - name: str, - value: str, - hidden: bool = False + cmd_id: str, + name: str, + value: str, + hidden: bool = False ) -> str: """ Create the HTML snippet for a detail associated with a command that @@ -375,9 +376,11 @@ def command_detail( The HTML snippet for this command detail. """ if hidden: - return hidden_command_detail_template.format(cmd_id=cmd_id, - name=name, - value=value) + return hidden_command_detail_template.format( + cmd_id=cmd_id, + name=name, + value=value + ) else: return command_detail_template.format(name=name, value=value) @@ -419,13 +422,13 @@ def command_card(log: dict, stream_dir: Path) -> Iterator[str]: ), output_block_card("stdout", stdout_path, cmd_id, collapsed=False), output_block_card("stderr", stderr_path, cmd_id, collapsed=False), - ] + ] # yapf: disable # Compile the additional diagnostic information. diagnostics = [ output_block_card("Environment", log["environment"], cmd_id), output_block_card("ulimit", log["ulimit"], cmd_id), - ] + ] # yapf: disable if trace_path.exists(): diagnostics.append(output_block_card("trace", trace_path, cmd_id)) @@ -437,10 +440,18 @@ def command_card(log: dict, stream_dir: Path) -> Iterator[str]: data = log["stats"][stat] diagnostics.append(time_series_plot(cmd_id, data, stat_title)) if log["stats"].get("disk"): - uninteresting_disks = ["/var", "/var/log", "/var/log/audit", - "/boot", "/boot/efi"] - disk_stats = {x: y for x, y in log["stats"]["disk"].items() - if x not in uninteresting_disks} + uninteresting_disks = [ + "/var", + "/var/log", + "/var/log/audit", + "/boot", + "/boot/efi" + ] + disk_stats = { + x: y + for x, y in log["stats"]["disk"].items() + if x not in uninteresting_disks + } # yapf: disable # We sort because JSON deserialization may change # the ordering of the map. @@ -451,10 +462,10 @@ def command_card(log: dict, stream_dir: Path) -> Iterator[str]: def time_series_plot( - cmd_id: str, - data_tuples: List[Tuple[float, float]], - series_title: str -) -> Iterator[str]: + cmd_id: str, + data_tuples: List[Tuple[float, float]], + series_title: str +) -> Iterator[str]: # yapf: disable """ Create the HTML for a plot of time series data. @@ -474,10 +485,10 @@ def time_series_plot( def disk_time_series_plot( - cmd_id: str, - data_tuples: Tuple[float, float], - volume_name: str -) -> Iterator[str]: + cmd_id: str, + data_tuples: Tuple[float, float], + volume_name: str +) -> Iterator[str]: # yapf: disable """ Create the HTML for a plot of the disk usage time series data for a particular volume. @@ -503,10 +514,10 @@ def disk_time_series_plot( def stat_chart_card( - labels: List[str], - data: List[float], - title: str, - identifier: str + labels: List[str], + data: List[float], + title: str, + identifier: str ) -> Iterator[str]: """ Create the HTML for a two-dimensional plot. @@ -520,18 +531,20 @@ def stat_chart_card( Yields: A HTML snippet for the chart with all the details filled in. """ - yield stat_chart_template.format(labels=labels, - data=data, - title=title, - id=identifier) + yield stat_chart_template.format( + labels=labels, + data=data, + title=title, + id=identifier + ) def output_block_card( - title: str, - output: Union[Path, str], - cmd_id: str, - collapsed: bool = True -) -> Iterator[str]: + title: str, + output: Union[Path, str], + cmd_id: str, + collapsed: bool = True +) -> Iterator[str]: # yapf: disable """ Given the output from a command, generate a corresponding HTML card for inclusion in the log file. @@ -549,13 +562,16 @@ def output_block_card( footer. """ name = title.replace(' ', '_').lower() - template = (output_card_collapsed_template if collapsed else - output_card_template) - header, indent, footer = split_template(template, - "output_block", - name=name, - title=title, - cmd_id=cmd_id) + template = ( + output_card_collapsed_template if collapsed else output_card_template + ) + header, indent, footer = split_template( + template, + "output_block", + name=name, + title=title, + cmd_id=cmd_id + ) yield header for line in output_block(output, name, cmd_id): yield textwrap.indent(line, indent) @@ -563,10 +579,10 @@ def output_block_card( def output_block( - output: Union[Path, str], - name: str, - cmd_id: str -) -> Iterator[str]: + output: Union[Path, str], + name: str, + cmd_id: str +) -> Iterator[str]: # yapf: disable """ Given the output from a command, generate the HTML equivalent for inclusion in the log file. @@ -604,9 +620,11 @@ def diagnostics_card(cmd_id: str, *args: Iterator[str]) -> Iterator[str]: The header, followed by each piece of diagnostic information, and then the footer. """ - header, indent, footer = split_template(diagnostics_template, - "diagnostics", - cmd_id=cmd_id) + header, indent, footer = split_template( + diagnostics_template, + "diagnostics", + cmd_id=cmd_id + ) yield header for arg in args: if isinstance(arg, str): @@ -618,10 +636,10 @@ def diagnostics_card(cmd_id: str, *args: Iterator[str]) -> Iterator[str]: def output_block_html( - lines: Union[TextIO, str], - name: str, - cmd_id: str -) -> Iterator[str]: + lines: Union[TextIO, str], + name: str, + cmd_id: str +) -> Iterator[str]: # yapf: disable """ Given the output of a command, generate its HTML equivalent for inclusion in the log file. @@ -638,10 +656,12 @@ def output_block_html( """ if isinstance(lines, str): lines = lines.split('\n') - header, indent, footer = split_template(output_block_template, - "table_contents", - name=name, - cmd_id=cmd_id) + header, indent, footer = split_template( + output_block_template, + "table_contents", + name=name, + cmd_id=cmd_id + ) yield header line_no = 0 for line in lines: @@ -651,10 +671,10 @@ def output_block_html( def split_template( - template: str, - split_at: str, - **kwargs -) -> Tuple[str, str, str]: + template: str, + split_at: str, + **kwargs +) -> Tuple[str, str, str]: # yapf: disable """ Take a templated HTML snippet and split it into a header and footer, meaning everything that comes before and after the line containing @@ -691,8 +711,10 @@ def split_template( The header, indent, and footer. """ fmt = {k: v for k, v in kwargs.items() if k != split_at} - pattern = re.compile(f"(.*\\n)(\\s*)\\{{{split_at}\\}}\\n(.*)", - flags=re.DOTALL) + pattern = re.compile( + f"(.*\\n)(\\s*)\\{{{split_at}\\}}\\n(.*)", + flags=re.DOTALL + ) before, indent, after = pattern.search(template).groups() return before.format(**fmt), indent, after.format(**fmt) @@ -729,7 +751,7 @@ def html_encode(text: str) -> str: .replace('&', "&") .replace('<', "<") .replace('>', ">") - ) + ) # yapf: disable def sgr_to_html(text: str) -> str: @@ -747,7 +769,7 @@ def sgr_to_html(text: str) -> str: while text.find("\x1b[") >= 0: start = text.find("\x1b[") finish = text.find("m", start) - sgrs = text[start+2:finish].split(';') + sgrs = text[start + 2:finish].split(';') span_string = "" if len(sgrs) == 0: span_string += "" * span_count @@ -770,7 +792,7 @@ def sgr_to_html(text: str) -> str: span_count += 1 span_string += sgr_4bit_color_and_style_to_html(sgrs[0]) sgrs = sgrs[1:] - text = text[:start] + span_string + text[finish+1:] + text = text[:start] + span_string + text[finish + 1:] return text @@ -790,23 +812,40 @@ def sgr_4bit_color_and_style_to_html(sgr: str) -> str: "3": "font-style: italic;", "4": "text-decoration: underline;", "9": "text-decoration: line-through;", - "30": "color: black;", "40": "background-color: black;", - "31": "color: red;", "41": "background-color: red;", - "32": "color: green;", "42": "background-color: green;", - "33": "color: yellow;", "43": "background-color: yellow;", - "34": "color: blue;", "44": "background-color: blue;", - "35": "color: magenta;", "45": "background-color: magenta;", - "36": "color: cyan;", "46": "background-color: cyan;", - "37": "color: white;", "47": "background-color: white;", - "90": "color: black;", "100": "background-color: black;", - "91": "color: red;", "101": "background-color: red;", - "92": "color: green;", "102": "background-color: green;", - "93": "color: yellow;", "103": "background-color: yellow;", - "94": "color: blue;", "104": "background-color: blue;", - "95": "color: magenta;", "105": "background-color: magenta;", - "96": "color: cyan;", "106": "background-color: cyan;", - "97": "color: white;", "107": "background-color: white;", - "39": "color: inherit;", "49": "background-color: inherit;", + "30": "color: black;", + "40": "background-color: black;", + "31": "color: red;", + "41": "background-color: red;", + "32": "color: green;", + "42": "background-color: green;", + "33": "color: yellow;", + "43": "background-color: yellow;", + "34": "color: blue;", + "44": "background-color: blue;", + "35": "color: magenta;", + "45": "background-color: magenta;", + "36": "color: cyan;", + "46": "background-color: cyan;", + "37": "color: white;", + "47": "background-color: white;", + "90": "color: black;", + "100": "background-color: black;", + "91": "color: red;", + "101": "background-color: red;", + "92": "color: green;", + "102": "background-color: green;", + "93": "color: yellow;", + "103": "background-color: yellow;", + "94": "color: blue;", + "104": "background-color: blue;", + "95": "color: magenta;", + "105": "background-color: magenta;", + "96": "color: cyan;", + "106": "background-color: cyan;", + "97": "color: white;", + "107": "background-color: white;", + "39": "color: inherit;", + "49": "background-color: inherit;", } return f'' @@ -826,26 +865,26 @@ def sgr_8bit_color_to_html(sgr_params: List[str]) -> str: if sgr_256 < 0 or sgr_256 > 255 or not sgr_params: return '' if 15 < sgr_256 < 232: - red_6cube = (sgr_256 - 16) // 36 - green_6cube = (sgr_256 - (16 + red_6cube * 36)) // 6 - blue_6cube = (sgr_256 - 16) % 6 + red_6cube = (sgr_256-16) // 36 + green_6cube = (sgr_256 - (16 + red_6cube*36)) // 6 + blue_6cube = (sgr_256-16) % 6 red = str(51 * red_6cube) green = str(51 * green_6cube) blue = str(51 * blue_6cube) return sgr_24bit_color_to_html([sgr_params[0], "2", red, green, blue]) elif 231 < sgr_256 < 256: - gray = str(8 + (sgr_256 - 232) * 10) + gray = str(8 + (sgr_256-232) * 10) return sgr_24bit_color_to_html([sgr_params[0], "2", gray, gray, gray]) elif sgr_params[0] == "38": if sgr_256 < 8: - return sgr_4bit_color_and_style_to_html(str(30+sgr_256)) + return sgr_4bit_color_and_style_to_html(str(30 + sgr_256)) elif sgr_256 < 16: - return sgr_4bit_color_and_style_to_html(str(82+sgr_256)) + return sgr_4bit_color_and_style_to_html(str(82 + sgr_256)) elif sgr_params[0] == "48": if sgr_256 < 8: - return sgr_4bit_color_and_style_to_html(str(40+sgr_256)) + return sgr_4bit_color_and_style_to_html(str(40 + sgr_256)) elif sgr_256 < 16: - return sgr_4bit_color_and_style_to_html(str(92+sgr_256)) + return sgr_4bit_color_and_style_to_html(str(92 + sgr_256)) def sgr_24bit_color_to_html(sgr_params: List[str]) -> str: @@ -875,25 +914,27 @@ def html_header() -> str: Returns: A string with the ``...`` contents. """ - return ("" - + embed_style("bootstrap.min.css") - + embed_style("Chart.min.css") - + embed_style("top_level_style_adjustments.css") - + embed_style("parent_logger_style.css") - + embed_style("child_logger_style.css") - + embed_style("command_style.css") - + embed_style("message_style.css") - + embed_style("detail_list_style.css") - + embed_style("code_block_style.css") - + embed_style("output_style.css") - + embed_style("diagnostics_style.css") - + embed_style("search_controls.css") - + embed_script("jquery.slim.min.js") - + embed_script("bootstrap.bundle.min.js") - + embed_script("Chart.bundle.min.js") - + embed_script("search_output.js") - + embed_html("search_icon.svg") - + "") + return ( + "" + + embed_style("bootstrap.min.css") + + embed_style("Chart.min.css") + + embed_style("top_level_style_adjustments.css") + + embed_style("parent_logger_style.css") + + embed_style("child_logger_style.css") + + embed_style("command_style.css") + + embed_style("message_style.css") + + embed_style("detail_list_style.css") + + embed_style("code_block_style.css") + + embed_style("output_style.css") + + embed_style("diagnostics_style.css") + + embed_style("search_controls.css") + + embed_script("jquery.slim.min.js") + + embed_script("bootstrap.bundle.min.js") + + embed_script("Chart.bundle.min.js") + + embed_script("search_output.js") + + embed_html("search_icon.svg") + + "" + ) # yapf: disable def embed_style(resource: str) -> str: @@ -911,9 +952,11 @@ def embed_style(resource: str) -> str: * Should we combine this with :func:`embed_script` and :func:`embed_html`? """ - return ("\n") + return ( + "\n" + ) # yapf: disable def embed_script(resource: str) -> str: @@ -927,9 +970,11 @@ def embed_script(resource: str) -> str: Returns: A string containing the ```` block. """ - return ("\n") + return ( + "\n" + ) # yapf: disable def embed_html(resource: str) -> str: diff --git a/src/shelllogger/shell.py b/src/shelllogger/shell.py index c227b62..bcb14d3 100644 --- a/src/shelllogger/shell.py +++ b/src/shelllogger/shell.py @@ -33,9 +33,9 @@ class Shell: """ def __init__( - self, - pwd: Path = Path.cwd(), - login_shell: bool = False + self, + pwd: Path = Path.cwd(), + login_shell: bool = False ) -> None: """ Initialize a :class:`Shell` object. @@ -55,18 +55,26 @@ def __init__( self.aux_stderr_rfd, self.aux_stderr_wfd = os.pipe() # Get the current flags of the file descriptors. - aux_stdout_write_flags = fcntl.fcntl(self.aux_stdout_wfd, - fcntl.F_GETFL) - aux_stderr_write_flags = fcntl.fcntl(self.aux_stderr_wfd, - fcntl.F_GETFL) + aux_stdout_write_flags = fcntl.fcntl( + self.aux_stdout_wfd, + fcntl.F_GETFL + ) + aux_stderr_write_flags = fcntl.fcntl( + self.aux_stderr_wfd, + fcntl.F_GETFL + ) # Make writes non-blocking. - fcntl.fcntl(self.aux_stdout_wfd, - fcntl.F_SETFL, - aux_stdout_write_flags | os.O_NONBLOCK) - fcntl.fcntl(self.aux_stderr_wfd, - fcntl.F_SETFL, - aux_stderr_write_flags | os.O_NONBLOCK) + fcntl.fcntl( + self.aux_stdout_wfd, + fcntl.F_SETFL, + aux_stdout_write_flags | os.O_NONBLOCK + ) + fcntl.fcntl( + self.aux_stderr_wfd, + fcntl.F_SETFL, + aux_stderr_write_flags | os.O_NONBLOCK + ) # Ensure the file descriptors are inheritable by the shell # subprocess. @@ -75,11 +83,13 @@ def __init__( shell_command = [os.environ.get("SHELL") or "/bin/sh"] if self.login_shell: shell_command.append("-l") - self.shell_subprocess = subprocess.Popen(shell_command, - stdin=self.aux_stdin_rfd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - close_fds=False) + self.shell_subprocess = subprocess.Popen( + shell_command, + stdin=self.aux_stdin_rfd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + close_fds=False + ) os.set_inheritable(self.aux_stdout_wfd, False) os.set_inheritable(self.aux_stderr_wfd, False) @@ -90,9 +100,14 @@ def __del__(self) -> None: """ Close all the open file descriptors. """ - for fd in [self.aux_stdin_rfd, self.aux_stdin_wfd, - self.aux_stdout_rfd, self.aux_stdout_wfd, - self.aux_stderr_rfd, self.aux_stderr_wfd]: + for fd in [ + self.aux_stdin_rfd, + self.aux_stdin_wfd, + self.aux_stdout_rfd, + self.aux_stdout_wfd, + self.aux_stderr_rfd, + self.aux_stderr_wfd + ]: try: os.close(fd) except OSError as e: @@ -152,8 +167,10 @@ def run(self, command: str, **kwargs) -> SimpleNamespace: # heredocs to tell the shell "this is one giant statement". # Then run the command. if kwargs.get("devnull_stdin"): - os.write(self.aux_stdin_wfd, - f"{{\n{command}\n}} SimpleNamespace: # Tee the output to multiple sinks (files, strings, # `stdout`/`stderr`). try: - output = self.tee(self.shell_subprocess.stdout, - self.shell_subprocess.stderr, **kwargs) + output = self.tee( + self.shell_subprocess.stdout, + self.shell_subprocess.stderr, + **kwargs + ) # Note: If something goes wrong in `tee()`, the only way to reliably # propagate an exception from a thread that's spawned is to raise a @@ -192,19 +212,21 @@ def run(self, command: str, **kwargs) -> SimpleNamespace: return_code = int(aux_out) except ValueError: return_code = "N/A" - return SimpleNamespace(returncode=return_code, - args=command, - stdout=output.stdout_str, - stderr=output.stderr_str, - start=start, - finish=finish, - wall=finish - start) + return SimpleNamespace( + returncode=return_code, + args=command, + stdout=output.stdout_str, + stderr=output.stderr_str, + start=start, + finish=finish, + wall=finish - start + ) @staticmethod def tee( - stdout: Optional[IO[bytes]], - stderr: Optional[IO[bytes]], - **kwargs + stdout: Optional[IO[bytes]], + stderr: Optional[IO[bytes]], + **kwargs ) -> SimpleNamespace: """ Split ``stdout`` and ``stderr`` file objects to write to @@ -266,7 +288,7 @@ def write(input_file: TextIO, output_files: List[TextIO]) -> None: threads = [ Thread(target=write, args=(stdout, stdout_tee)), Thread(target=write, args=(stderr, stderr_tee)), - ] + ] # yapf: disable for thread in threads: thread.daemon = True thread.start() @@ -278,18 +300,17 @@ def write(input_file: TextIO, output_files: List[TextIO]) -> None: # Close any open file descriptors and return the `stdout` and # `stderr`. for file in (stdout_tee + stderr_tee): - if (file not in [None, sys.stdout, sys.stderr, sys.stdin] - and not file.closed): + if ( + file not in [None, sys.stdout, sys.stderr, sys.stdin] + and not file.closed + ): # yapf: disable file.close() - return SimpleNamespace( - stdout_str=stdout_str, - stderr_str=stderr_str - ) + return SimpleNamespace(stdout_str=stdout_str, stderr_str=stderr_str) def auxiliary_command( self, **kwargs - ) -> Tuple[Optional[str], Optional[str]]: + ) -> Tuple[Optional[str], Optional[str]]: # yapf: disable """ Run auxiliary commands like `umask`, `pwd`, `env`, etc. @@ -323,15 +344,19 @@ def auxiliary_command( aux = os.read(self.aux_stdout_rfd, max_anonymous_pipe_buffer_size) while aux[-1] != 4: stdout += aux.decode() - aux = os.read(self.aux_stdout_rfd, - max_anonymous_pipe_buffer_size) + aux = os.read( + self.aux_stdout_rfd, + max_anonymous_pipe_buffer_size + ) aux = aux[:-1] stdout += aux.decode() aux = os.read(self.aux_stderr_rfd, max_anonymous_pipe_buffer_size) while aux[-1] != 4: stderr += aux.decode() - aux = os.read(self.aux_stderr_rfd, - max_anonymous_pipe_buffer_size) + aux = os.read( + self.aux_stderr_rfd, + max_anonymous_pipe_buffer_size + ) aux = aux[:-1] stderr += aux.decode() if kwargs.get("strip"): diff --git a/src/shelllogger/shell_logger.py b/src/shelllogger/shell_logger.py index 2b49d24..67e7ea1 100644 --- a/src/shelllogger/shell_logger.py +++ b/src/shelllogger/shell_logger.py @@ -4,10 +4,17 @@ from .shell import Shell from .stats_collector import stats_collectors from .trace import trace_collector -from .html_utilities import (nested_simplenamespace_to_dict, opening_html_text, - closing_html_text, append_html, html_message_card, - message_card, command_card, child_logger_card, - parent_logger_card_html) +from .html_utilities import ( + nested_simplenamespace_to_dict, + opening_html_text, + closing_html_text, + append_html, + html_message_card, + message_card, + command_card, + child_logger_card, + parent_logger_card_html +) from collections.abc import Iterable, Mapping from datetime import datetime, timedelta from typing import Iterator, List, Optional, Union @@ -113,17 +120,17 @@ def append(path: Path) -> ShellLogger: return loaded_logger def __init__( - self, - name: str, - log_dir: Path = Path.cwd(), - stream_dir: Optional[Path] = None, - html_file: Optional[Path] = None, - indent: int = 0, - login_shell: bool = False, - log: Optional[List[object]] = None, - init_time: Optional[datetime] = None, - done_time: Optional[datetime] = None, - duration: Optional[str] = None + self, + name: str, + log_dir: Path = Path.cwd(), + stream_dir: Optional[Path] = None, + html_file: Optional[Path] = None, + indent: int = 0, + login_shell: bool = False, + log: Optional[List[object]] = None, + init_time: Optional[datetime] = None, + done_time: Optional[datetime] = None, + duration: Optional[str] = None ) -> None: """ Initialize a :class:`ShellLogger` object. @@ -162,7 +169,7 @@ def __init__( self.name = name self.log_book: List[Union[dict, ShellLogger]] = ( log if log is not None else [] - ) + ) # yapf: disable self.init_time = datetime.now() if init_time is None else init_time self.done_time = datetime.now() if done_time is None else done_time self.duration = duration @@ -178,24 +185,28 @@ def __init__( # If there isn't a `stream_dir` given by the parent ShellLogger, this # is the parent; create the `stream_dir`. if stream_dir is None: - self.stream_dir = Path(tempfile.mkdtemp( - dir=self.log_dir, - prefix=self.init_time.strftime("%Y-%m-%d_%H.%M.%S.%f_") - )).resolve() + self.stream_dir = Path( + tempfile.mkdtemp( + dir=self.log_dir, + prefix=self.init_time.strftime("%Y-%m-%d_%H.%M.%S.%f_") + ) + ).resolve() else: self.stream_dir = stream_dir.resolve() # Create (or append to) the HTML log file. if html_file is None: - self.html_file = self.stream_dir / (self.name.replace(' ', '_') - + '.html') + self.html_file = self.stream_dir / ( + self.name.replace(' ', '_') + '.html' + ) # yapf: disable else: self.html_file = html_file.resolve() if self.is_parent(): if self.html_file.exists(): with open(self.html_file, 'a') as f: - f.write(f"") + f.write( + f"" + ) else: self.html_file.touch() @@ -225,8 +236,10 @@ def __update_duration(self) -> None: now. """ self.update_done_time() - self.duration = self.strfdelta(self.done_time - self.init_time, - "{hrs}h {min}m {sec}s") + self.duration = self.strfdelta( + self.done_time - self.init_time, + "{hrs}h {min}m {sec}s" + ) def check_duration(self) -> str: """ @@ -236,8 +249,10 @@ def check_duration(self) -> str: Returns: A string representation of the total duration. """ - return self.strfdelta(datetime.now() - self.init_time, - "{hrs}h {min}m {sec}s") + return self.strfdelta( + datetime.now() - self.init_time, + "{hrs}h {min}m {sec}s" + ) def change_log_dir(self, new_log_dir: Path) -> None: """ @@ -252,8 +267,10 @@ def change_log_dir(self, new_log_dir: Path) -> None: :class:`ShellLogger`. """ if not self.is_parent(): - raise RuntimeError("You should not change the log directory of a " - "child `ShellLogger`; only that of the parent.") + raise RuntimeError( + "You should not change the log directory of a child " + "`ShellLogger`; only that of the parent." + ) # This only gets executed once by the top-level parent # `ShellLogger` object. @@ -263,8 +280,9 @@ def change_log_dir(self, new_log_dir: Path) -> None: # Change the `stream_dir`, `html_file`, and `log_dir` for every # child `ShellLogger` recursively. - self.stream_dir = (new_log_dir - / self.stream_dir.relative_to(self.log_dir)) + self.stream_dir = ( + new_log_dir / self.stream_dir.relative_to(self.log_dir) + ) self.html_file = new_log_dir / self.html_file.relative_to(self.log_dir) self.log_dir = new_log_dir.resolve() for log in self.log_book: @@ -288,8 +306,14 @@ def add_child(self, child_name: str) -> ShellLogger: """ # Create the child object and add it to the list of children. - child = ShellLogger(child_name, self.log_dir, self.stream_dir, - self.html_file, self.indent + 1, self.login_shell) + child = ShellLogger( + child_name, + self.log_dir, + self.stream_dir, + self.html_file, + self.indent + 1, + self.login_shell + ) self.log_book.append(child) return child @@ -312,8 +336,9 @@ def strfdelta(delta: timedelta, fmt: str) -> str: microseconds_per_second = 10**6 seconds_per_minute = 60 minutes_per_hour = 60 - total_ms = delta.microseconds + (delta.seconds - * microseconds_per_second) + total_ms = ( + delta.microseconds + delta.seconds * microseconds_per_second + ) d['hrs'], rem = divmod(total_ms, (minutes_per_hour * seconds_per_minute * microseconds_per_second)) @@ -336,11 +361,7 @@ def print(self, msg: str, end: str = '\n') -> None: end: The string appended after the message: """ print(msg, end=end) - log = { - 'msg': msg, - 'timestamp': str(datetime.now()), - 'cmd': None - } + log = {'msg': msg, 'timestamp': str(datetime.now()), 'cmd': None} self.log_book.append(log) def html_print(self, msg: str, msg_title: str = "HTML Message") -> None: @@ -427,23 +448,29 @@ def finalize(self) -> None: # Save everything to a JSON file in the timestamped # `stream_dir`. - json_file = self.stream_dir / (self.name.replace(' ', '_') - + '.json') + json_file = self.stream_dir / ( + self.name.replace(' ', '_') + '.json' + ) # yapf: disable with open(json_file, 'w') as jf: - json.dump(self, jf, cls=ShellLoggerEncoder, sort_keys=True, - indent=4) + json.dump( + self, + jf, + cls=ShellLoggerEncoder, + sort_keys=True, + indent=4 + ) def log( - self, - msg: str, - cmd: str, - cwd: Optional[Path] = None, - live_stdout: bool = False, - live_stderr: bool = False, - return_info: bool = False, - verbose: bool = False, - stdin_redirect: bool = True, - **kwargs + self, + msg: str, + cmd: str, + cwd: Optional[Path] = None, + live_stdout: bool = False, + live_stderr: bool = False, + return_info: bool = False, + verbose: bool = False, + stdin_redirect: bool = True, + **kwargs ) -> dict: """ Execute a command, and log the corresponding information. @@ -491,15 +518,18 @@ def log( # Create a unique command ID that will be used to find the # location of the `stdout`/`stderr` files in the temporary # directory during finalization. - cmd_id = 'cmd_' + ''.join(random.choice(string.ascii_lowercase) - for _ in range(9)) + cmd_id = 'cmd_' + ''.join( + random.choice(string.ascii_lowercase) for _ in range(9) + ) # Create & open files for `stdout`, `stderr`, and trace data. time_str = start_time.strftime("%Y-%m-%d_%H%M%S") stdout_path = self.stream_dir / f"{time_str}_{cmd_id}_stdout" stderr_path = self.stream_dir / f"{time_str}_{cmd_id}_stderr" - trace_path = (self.stream_dir / f"{time_str}_{cmd_id}_trace" - if kwargs.get("trace") else None) + trace_path = ( + self.stream_dir / f"{time_str}_{cmd_id}_trace" + if kwargs.get("trace") else None + ) # yapf: disable # Print the command to be executed. with open(stdout_path, 'a'), open(stderr_path, 'a'): @@ -507,27 +537,31 @@ def log( print(cmd) # Initialize the log information. - log = {'msg': msg, - 'duration': None, - 'timestamp': start_time.strftime("%Y-%m-%d_%H%M%S"), - 'cmd': cmd, - 'cmd_id': cmd_id, - 'cwd': cwd, - 'return_code': 0} + log = { + 'msg': msg, + 'duration': None, + 'timestamp': start_time.strftime("%Y-%m-%d_%H%M%S"), + 'cmd': cmd, + 'cmd_id': cmd_id, + 'cwd': cwd, + 'return_code': 0 + } # Execute the command. - result = self._run(cmd, - quiet_stdout=not live_stdout, - quiet_stderr=not live_stderr, - stdout_str=return_info, - stderr_str=return_info, - trace_str=return_info, - stdout_path=stdout_path, - stderr_path=stderr_path, - trace_path=trace_path, - devnull_stdin=stdin_redirect, - pwd=cwd, - **kwargs) + result = self._run( + cmd, + quiet_stdout=not live_stdout, + quiet_stderr=not live_stderr, + stdout_str=return_info, + stderr_str=return_info, + trace_str=return_info, + stdout_path=stdout_path, + stderr_path=stderr_path, + trace_path=trace_path, + devnull_stdin=stdin_redirect, + pwd=cwd, + **kwargs + ) # Update the log information and save it to the `log_book`. h = int(result.wall / 3600000) @@ -537,8 +571,11 @@ def log( log["return_code"] = result.returncode log = {**log, **nested_simplenamespace_to_dict(result)} self.log_book.append(log) - return {'return_code': log['return_code'], - 'stdout': result.stdout, 'stderr': result.stderr} + return { + 'return_code': log['return_code'], + 'stdout': result.stdout, + 'stderr': result.stderr + } def _run(self, command: str, **kwargs) -> SimpleNamespace: """ @@ -609,8 +646,10 @@ def _run(self, command: str, **kwargs) -> SimpleNamespace: # Change back to the original directory and return the results. if kwargs.get("pwd"): self.shell.cd(old_pwd) - return SimpleNamespace(**completed_process.__dict__, - **aux_info.__dict__) + return SimpleNamespace( + **completed_process.__dict__, + **aux_info.__dict__ + ) def auxiliary_information(self) -> SimpleNamespace: """ @@ -631,14 +670,16 @@ def auxiliary_information(self) -> SimpleNamespace: shell, _ = self.shell.auxiliary_command(posix="printenv SHELL", strip=True) ulimit, _ = self.shell.auxiliary_command(posix="ulimit -a") - return SimpleNamespace(pwd=pwd, - environment=environment, - umask=umask, - hostname=hostname, - user=user, - group=group, - shell=shell, - ulimit=ulimit) + return SimpleNamespace( + pwd=pwd, + environment=environment, + umask=umask, + hostname=hostname, + user=user, + group=group, + shell=shell, + ulimit=ulimit + ) class ShellLoggerEncoder(json.JSONEncoder): @@ -665,30 +706,34 @@ def default(self, obj: object) -> object: The JSON serialization of the given object. """ if isinstance(obj, ShellLogger): - return {**{'__type__': 'ShellLogger'}, - **{k: self.default(v) for k, v in obj.__dict__.items()}} + return { + **{'__type__': 'ShellLogger'}, + **{k: self.default(v) for k, v in obj.__dict__.items()} + } # yapf: disable elif isinstance(obj, (int, float, str, bytes)): return obj elif isinstance(obj, Mapping): return {k: self.default(v) for k, v in obj.items()} elif isinstance(obj, tuple): - return {'__type__': 'tuple', - 'items': obj} + return {'__type__': 'tuple', 'items': obj} elif isinstance(obj, Iterable): return [self.default(x) for x in obj] elif isinstance(obj, datetime): - return {'__type__': 'datetime', - 'value': obj.strftime('%Y-%m-%d_%H:%M:%S:%f'), - 'format': '%Y-%m-%d_%H:%M:%S:%f'} + return { + '__type__': 'datetime', + 'value': obj.strftime('%Y-%m-%d_%H:%M:%S:%f'), + 'format': '%Y-%m-%d_%H:%M:%S:%f' + } elif isinstance(obj, Path): - return {'__type__': 'Path', - 'value': str(obj)} + return {'__type__': 'Path', 'value': str(obj)} elif obj is None: return None elif isinstance(obj, Shell): - return {"__type__": "Shell", - "pwd": obj.pwd(), - "login_shell": obj.login_shell} + return { + "__type__": "Shell", + "pwd": obj.pwd(), + "login_shell": obj.login_shell + } else: return json.JSONEncoder.default(self, obj) @@ -727,11 +772,18 @@ def dict_to_object(obj: dict) -> object: if '__type__' not in obj: return obj elif obj['__type__'] == 'ShellLogger': - logger = ShellLogger(obj["name"], obj["log_dir"], - obj["stream_dir"], obj["html_file"], - obj["indent"], obj["login_shell"], - obj["log_book"], obj["init_time"], - obj["done_time"], obj["duration"]) + logger = ShellLogger( + obj["name"], + obj["log_dir"], + obj["stream_dir"], + obj["html_file"], + obj["indent"], + obj["login_shell"], + obj["log_book"], + obj["init_time"], + obj["done_time"], + obj["duration"] + ) return logger elif obj['__type__'] == 'datetime': return datetime.strptime(obj['value'], obj['format']) diff --git a/src/shelllogger/stats_collector.py b/src/shelllogger/stats_collector.py index cd8e4b4..30a420b 100644 --- a/src/shelllogger/stats_collector.py +++ b/src/shelllogger/stats_collector.py @@ -122,6 +122,7 @@ def finish(self): if psutil is not None: + @StatsCollector.subclass class DiskStatsCollector(StatsCollector): """ @@ -144,11 +145,15 @@ def __init__(self, interval: float, manager: SyncManager) -> None: self.mount_points = [ p.mountpoint for p in psutil.disk_partitions() ] - for location in ["/tmp", - "/dev/shm", - f"/var/run/user/{os.getuid()}"]: - if (location not in self.mount_points - and Path(location).exists()): + for location in [ + "/tmp", + "/dev/shm", + f"/var/run/user/{os.getuid()}" + ]: + if ( + location not in self.mount_points + and Path(location).exists() + ): self.mount_points.append(location) for m in self.mount_points: self.stats[m] = manager.list() @@ -249,8 +254,10 @@ def unproxied_stats(self) -> List[Tuple[float, float]]: """ return list(self.stats) + # If we don't have `psutil`, return null objects. else: + @StatsCollector.subclass class DiskStatsCollector(StatsCollector): """ diff --git a/tests/test_shell_logger.py b/tests/test_shell_logger.py index 867c75e..2105016 100644 --- a/tests/test_shell_logger.py +++ b/tests/test_shell_logger.py @@ -49,18 +49,22 @@ def shell_logger() -> ShellLogger: # Run the command. # `stdout` ; `stderr` - cmd = ("sleep 1; echo 'Hello world out'; sleep 1; echo 'Hello world " - "error' 1>&2") - kwargs = {"measure": ["cpu", "memory", "disk"], - "return_info": True, - "interval": 0.1} + cmd = ( + "sleep 1; echo 'Hello world out'; sleep 1; echo 'Hello world error' " + "1>&2" + ) + measure = ["cpu", "memory", "disk"] + kwargs = {"measure": measure, "return_info": True, "interval": 0.1} if os.uname().sysname == "Linux": - kwargs.update({"trace": "ltrace", - "expression": "setlocale", - "summary": True}) + kwargs.update({ + "trace": "ltrace", + "expression": "setlocale", + "summary": True + }) else: - print(f"Warning: uname is not 'Linux': {os.uname()}; ltrace not " - "tested.") + print( + f"Warning: uname is not 'Linux': {os.uname()}; ltrace not tested." + ) parent.log("test cmd", cmd, Path.cwd(), **kwargs) parent.print("This is a message") @@ -99,7 +103,7 @@ def test_initialization_creates_html_file() -> None: def test_log_method_creates_tmp_stdout_stderr_files( - shell_logger: ShellLogger + shell_logger: ShellLogger ) -> None: """ Verify that logging a command will create files in the @@ -160,9 +164,9 @@ def test_log_method_return_info_works_correctly(return_info: bool) -> None: @pytest.mark.parametrize('live_stdout', [True, False]) @pytest.mark.parametrize('live_stderr', [True, False]) def test_log_method_live_stdout_stderr_works_correctly( - capsys: CaptureFixture, - live_stdout: bool, - live_stderr: bool + capsys: CaptureFixture, + live_stdout: bool, + live_stderr: bool ) -> None: """ Verify that the ``live_stdout`` and ``live_stdout`` flags work as @@ -176,8 +180,6 @@ def test_log_method_live_stdout_stderr_works_correctly( the :func:`log` command. """ logger = ShellLogger(stack()[0][3], Path.cwd()) - - # `stdout` ; `stderr` cmd = "echo 'Hello world out'; echo 'Hello world error' 1>&2" logger.log("test cmd", cmd, Path.cwd(), live_stdout, live_stderr) out, err = capsys.readouterr() @@ -192,7 +194,7 @@ def test_log_method_live_stdout_stderr_works_correctly( def test_child_logger_duration_displayed_correctly_in_html( - shell_logger: ShellLogger + shell_logger: ShellLogger ) -> None: """ Verify that the overview of child loggers in the HTML file displays @@ -216,7 +218,7 @@ def test_child_logger_duration_displayed_correctly_in_html( def test_finalize_creates_json_with_correct_information( - shell_logger: ShellLogger + shell_logger: ShellLogger ) -> None: """ Verify that the :func:`finalize` method creates a JSON file with the @@ -257,7 +259,7 @@ def test_finalize_creates_json_with_correct_information( def test_finalize_creates_html_with_correct_information( - shell_logger: ShellLogger + shell_logger: ShellLogger ) -> None: """ Verify that the :func:`finalize` method creates an HTML file with @@ -278,8 +280,10 @@ def test_finalize_creates_html_with_correct_information( assert ">test cmd {shell_logger.log_book[0]['timestamp']}" in html_text - assert ("Command:
sleep 1; echo 'Hello world out'; "
-            "sleep 1; echo 'Hello world error' 1>&2") in html_text
+    assert (
+        "Command: 
sleep 1; echo 'Hello world out'; sleep 1; "
+        "echo 'Hello world error' 1>&2"
+    ) in html_text
     assert f"CWD: {Path.cwd()}" in html_text
     assert "Return Code: 0" in html_text
 
@@ -289,8 +293,9 @@ def test_finalize_creates_html_with_correct_information(
         assert "traceMemory Usage' in html_text
     assert " None:
     """
     Verify that the :func:`finalize` method symlinks
@@ -331,8 +336,8 @@ def test_log_dir_html_symlinks_to_stream_dir_html(
 
 
 def test_json_file_can_reproduce_html_file(
-        shell_logger: ShellLogger
-) -> None:
+    shell_logger: ShellLogger
+) -> None:  # yapf: disable
     """
     Verify that a JSON file can properly recreate the original HTML file
     created when :func:`finalize` is called.
@@ -373,8 +378,10 @@ def test_under_stress() -> None:
     Test that all is well when handling lots of output.
     """
     logger = ShellLogger(stack()[0][3], Path.cwd())
-    cmd = ("dd if=/dev/urandom bs=1024 count=262144 | "
-           "LC_ALL=C tr -c '[:print:]' '*' ; sleep 1")
+    cmd = (
+        "dd if=/dev/urandom bs=1024 count=262144 | "
+        "LC_ALL=C tr -c '[:print:]' '*' ; sleep 1"
+    )
     msg = "Get 256 MB of stdout from /dev/urandom"
     logger.log(msg, cmd)
     assert logger.log_book[0]["returncode"] == 0
@@ -421,8 +428,10 @@ def test_logger_does_not_store_stdout_string_by_default() -> None:
     Ensure we don't hold a commands ``stdout`` in memory by default.
     """
     logger = ShellLogger(stack()[0][3], Path.cwd())
-    cmd = ("dd if=/dev/urandom bs=1024 count=262144 | "
-           "LC_ALL=C tr -c '[:print:]' '*' ; sleep 1")
+    cmd = (
+        "dd if=/dev/urandom bs=1024 count=262144 | "
+        "LC_ALL=C tr -c '[:print:]' '*' ; sleep 1"
+    )
     msg = "Get 256 MB of stdout from /dev/urandom"
     logger.log(msg, cmd)
     mem_usage = psutil.Process().memory_info().rss
@@ -433,8 +442,10 @@ def test_logger_does_not_store_stdout_string_by_default() -> None:
     assert mem_usage > bytes_in_128_mb
 
 
-@pytest.mark.skipif(os.uname().sysname == "Darwin",
-                    reason="`ltrace` doesn't exist for Darwin")
+@pytest.mark.skipif(
+    os.uname().sysname == "Darwin",
+    reason="`ltrace` doesn't exist for Darwin"
+)
 def test_logger_does_not_store_trace_string_by_default() -> None:
     """
     Ensure we don't keep trace output in memory by default.
@@ -442,8 +453,13 @@ def test_logger_does_not_store_trace_string_by_default() -> None:
     logger = ShellLogger(stack()[0][3], Path.cwd())
     logger.log("echo hello", "echo hello", Path.cwd(), trace="ltrace")
     assert logger.log_book[0]["trace"] is None
-    logger.log("echo hello", "echo hello", Path.cwd(), return_info=True,
-               trace="ltrace")
+    logger.log(
+        "echo hello",
+        "echo hello",
+        Path.cwd(),
+        return_info=True,
+        trace="ltrace"
+    )
     assert logger.log_book[1]["trace"] is not None
 
 
@@ -516,8 +532,10 @@ def test_auxiliary_data() -> None:
         assert logger._run("printenv SHELL").stdout.strip() == result.shell
         assert logger._run("ulimit -a").stdout == result.ulimit
     else:
-        print(f"Warning: os.name is not 'posix': {os.name}; umask, "
-              "group, shell, and ulimit not tested.")
+        print(
+            f"Warning: os.name is not 'posix': {os.name}; umask, group, "
+            "shell, and ulimit not tested."
+        )
 
 
 def test_working_directory() -> None:
@@ -547,8 +565,10 @@ def test_trace() -> None:
         result = logger._run("echo hello", trace="strace")
         assert f'execve("{echo_location}' in result.trace
     else:
-        print(f"Warning: uname is not 'Linux': {os.uname()}; strace/ltrace "
-              "not tested.")
+        print(
+            f"Warning: uname is not 'Linux': {os.uname()}; strace/ltrace not "
+            "tested."
+        )
 
 
 def test_trace_expression() -> None:
@@ -561,8 +581,10 @@ def test_trace_expression() -> None:
         assert 'getenv("POSIXLY_CORRECT")' in result.trace
         assert result.trace.count('\n') == 2
     else:
-        print(f"Warning: uname is not 'Linux': {os.uname()}; ltrace "
-              "expression not tested.")
+        print(
+            f"Warning: uname is not 'Linux': {os.uname()}; ltrace expression "
+            "not tested."
+        )
 
 
 def test_trace_summary() -> None:
@@ -579,8 +601,10 @@ def test_trace_summary() -> None:
         assert f'execve("{echo_location}' not in result.trace
         assert "execve" in result.trace
     else:
-        print(f"Warning: uname is not 'Linux': {os.uname()}; strace/ltrace "
-              "summary not tested.")
+        print(
+            f"Warning: uname is not 'Linux': {os.uname()}; strace/ltrace "
+            "summary not tested."
+        )
 
 
 def test_trace_expression_and_summary() -> None:
@@ -591,19 +615,29 @@ def test_trace_expression_and_summary() -> None:
     logger = ShellLogger(stack()[0][3], Path.cwd())
     if os.uname().sysname == "Linux":
         echo_location = logger._run("which echo").stdout.strip()
-        result = logger._run("echo hello", trace="strace", expression="execve",
-                             summary=True)
+        result = logger._run(
+            "echo hello",
+            trace="strace",
+            expression="execve",
+            summary=True
+        )
         assert f'execve("{echo_location}' not in result.trace
         assert "execve" in result.trace
         assert "getenv" not in result.trace
-        result = logger._run("echo hello", trace="ltrace", expression="getenv",
-                             summary=True)
+        result = logger._run(
+            "echo hello",
+            trace="ltrace",
+            expression="getenv",
+            summary=True
+        )
         assert 'getenv("POSIXLY_CORRECT")' not in result.trace
         assert "getenv" in result.trace
         assert "strcmp" not in result.trace
     else:
-        print(f"Warning: uname is not 'Linux': {os.uname()}; strace/ltrace "
-              "expression+summary not tested.")
+        print(
+            f"Warning: uname is not 'Linux': {os.uname()}; strace/ltrace "
+            "expression+summary not tested."
+        )
 
 
 def test_stats() -> None:
@@ -611,8 +645,8 @@ def test_stats() -> None:
     Ensure capturing CPU, memory, and disk statistics works correctly.
     """
     logger = ShellLogger(stack()[0][3], Path.cwd())
-    result = logger._run("sleep 2", measure=["cpu", "memory", "disk"],
-                         interval=0.1)
+    measure = ["cpu", "memory", "disk"],
+    result = logger._run("sleep 2", measure=measure, interval=0.1)
     assert len(result.stats["memory"]) > 1
     assert len(result.stats["memory"]) < 30
     assert len(result.stats["cpu"]) > 1
@@ -621,8 +655,10 @@ def test_stats() -> None:
         assert len(result.stats["disk"]["/"]) > 1
         assert len(result.stats["disk"]["/"]) < 30
     else:
-        print(f"Warning: os.name is not 'posix': {os.name}; disk usage not "
-              "fully tested.")
+        print(
+            f"Warning: os.name is not 'posix': {os.name}; disk usage not "
+            "fully tested."
+        )
 
 
 def test_trace_and_stats() -> None:
@@ -632,9 +668,15 @@ def test_trace_and_stats() -> None:
     """
     logger = ShellLogger(stack()[0][3], Path.cwd())
     if os.uname().sysname == "Linux":
-        result = logger._run("sleep 1", measure=["cpu", "memory", "disk"],
-                             interval=0.1, trace="ltrace",
-                             expression="setlocale", summary=True)
+        measure = ["cpu", "memory", "disk"],
+        result = logger._run(
+            "sleep 1",
+            measure=measure,
+            interval=0.1,
+            trace="ltrace",
+            expression="setlocale",
+            summary=True
+        )
         assert "setlocale" in result.trace
         assert "sleep" not in result.trace
         assert len(result.stats["memory"]) > 5
@@ -644,8 +686,9 @@ def test_trace_and_stats() -> None:
         assert len(result.stats["disk"]["/"]) > 5
         assert len(result.stats["disk"]["/"]) < 50
     else:
-        print(f"Warning: uname is not 'Linux': {os.uname()}; ltrace not "
-              "tested.")
+        print(
+            f"Warning: uname is not 'Linux': {os.uname()}; ltrace not tested."
+        )
 
 
 def test_trace_and_stat() -> None:
@@ -655,21 +698,29 @@ def test_trace_and_stat() -> None:
     """
     logger = ShellLogger(stack()[0][3], Path.cwd())
     if os.uname().sysname == "Linux":
-        result = logger._run("sleep 1", measure=["cpu"], interval=0.1,
-                             trace="ltrace", expression="setlocale",
-                             summary=True)
+        result = logger._run(
+            "sleep 1",
+            measure=["cpu"],
+            interval=0.1,
+            trace="ltrace",
+            expression="setlocale",
+            summary=True
+        )
         assert "setlocale" in result.trace
         assert "sleep" not in result.trace
         assert result.stats.get("memory") is None
         assert result.stats.get("disk") is None
         assert result.stats.get("cpu") is not None
     else:
-        print(f"Warning: uname is not 'Linux': {os.uname()}; ltrace not "
-              "tested.")
+        print(
+            f"Warning: uname is not 'Linux': {os.uname()}; ltrace not tested."
+        )
 
 
-@pytest.mark.skipif(os.uname().sysname == "Darwin",
-                    reason="`ltrace`/`strace` don't exist for Darwin")
+@pytest.mark.skipif(
+    os.uname().sysname == "Darwin",
+    reason="`ltrace`/`strace` don't exist for Darwin"
+)
 @pytest.mark.skip(reason="Not sure it's worth it to fix this or not")
 def test_set_env_trace() -> None:
     """
@@ -689,9 +740,17 @@ def test_log_book_trace_and_stats() -> None:
     """
     if os.uname().sysname == "Linux":
         logger = ShellLogger(stack()[0][3], Path.cwd())
-        logger.log("Sleep", "sleep 1", return_info=True,
-                   measure=["cpu", "memory", "disk"], interval=0.1,
-                   trace="ltrace", expression="setlocale", summary=True)
+        measure = ["cpu", "memory", "disk"],
+        logger.log(
+            "Sleep",
+            "sleep 1",
+            return_info=True,
+            measure=measure,
+            interval=0.1,
+            trace="ltrace",
+            expression="setlocale",
+            summary=True
+        )
         assert "setlocale" in logger.log_book[0]["trace"]
         assert "sleep" not in logger.log_book[0]["trace"]
         assert len(logger.log_book[0]["stats"]["memory"]) > 5
@@ -701,8 +760,9 @@ def test_log_book_trace_and_stats() -> None:
         assert len(logger.log_book[0]["stats"]["disk"]["/"]) > 5
         assert len(logger.log_book[0]["stats"]["disk"]["/"]) < 50
     else:
-        print(f"Warning: uname is not 'Linux': {os.uname()}; ltrace not "
-              "tested.")
+        print(
+            f"Warning: uname is not 'Linux': {os.uname()}; ltrace not tested."
+        )
 
 
 def test_change_pwd() -> None:
@@ -747,8 +807,9 @@ def test_sgr_gets_converted_to_html() -> None:
     logger.print("\x1B[31mHello\x1B[0m")
     logger.print("\x1B[31;43m\x1B[4mthere\x1B[0m")
     logger.print("\x1B[38;5;196m\x1B[48;5;232m\x1B[4mmr.\x1B[0m logger")
-    logger.print("\x1B[38;2;96;140;240m\x1B[48;2;240;140;10mmrs.\x1B[0m "
-                 "logger")
+    logger.print(
+        "\x1B[38;2;96;140;240m\x1B[48;2;240;140;10mmrs.\x1B[0m logger"
+    )
     logger.finalize()
 
     # Load the HTML file and make sure it checks out.
@@ -777,8 +838,10 @@ def test_html_print(capsys: CaptureFixture) -> None:
         capsys:  A fixture for capturing the ``stdout``.
     """
     logger = ShellLogger(stack()[0][3], Path.cwd())
-    logger.html_print("The quick brown fox jumps over the lazy dog.",
-                      msg_title="Brown Fox")
+    logger.html_print(
+        "The quick brown fox jumps over the lazy dog.",
+        msg_title="Brown Fox"
+    )
     logger.print("The quick orange zebra jumps over the lazy dog.")
     out, err = capsys.readouterr()
     logger.finalize()
@@ -860,7 +923,9 @@ def test_invalid_decodings() -> None:
     Ensure we appropriately handle invalid bytes when decoding output.
     """
     logger = ShellLogger(stack()[0][3], Path.cwd())
-    result = logger.log("Print invalid start byte for bytes decode()",
-                        "printf '\\xFDHello\\n'",
-                        return_info=True)
+    result = logger.log(
+        "Print invalid start byte for bytes decode()",
+        "printf '\\xFDHello\\n'",
+        return_info=True
+    )
     assert result["stdout"] == "Hello\n"

From 4f160ac4b1fba72e813dc881f75dad89357c5e9c Mon Sep 17 00:00:00 2001
From: "Jason M. Gates" 
Date: Thu, 24 Mar 2022 10:04:40 -0600
Subject: [PATCH 10/11] Fix broken test

---
 tests/test_shell_logger.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tests/test_shell_logger.py b/tests/test_shell_logger.py
index 2105016..eabb69e 100644
--- a/tests/test_shell_logger.py
+++ b/tests/test_shell_logger.py
@@ -645,7 +645,7 @@ def test_stats() -> None:
     Ensure capturing CPU, memory, and disk statistics works correctly.
     """
     logger = ShellLogger(stack()[0][3], Path.cwd())
-    measure = ["cpu", "memory", "disk"],
+    measure = ["cpu", "memory", "disk"]
     result = logger._run("sleep 2", measure=measure, interval=0.1)
     assert len(result.stats["memory"]) > 1
     assert len(result.stats["memory"]) < 30

From 31e16b36ddf1471980d1d924a38fbb870436eafa Mon Sep 17 00:00:00 2001
From: "Jason M. Gates" 
Date: Thu, 24 Mar 2022 10:39:47 -0600
Subject: [PATCH 11/11] Fix broken tests

---
 tests/test_shell_logger.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/tests/test_shell_logger.py b/tests/test_shell_logger.py
index eabb69e..0052fe5 100644
--- a/tests/test_shell_logger.py
+++ b/tests/test_shell_logger.py
@@ -668,7 +668,7 @@ def test_trace_and_stats() -> None:
     """
     logger = ShellLogger(stack()[0][3], Path.cwd())
     if os.uname().sysname == "Linux":
-        measure = ["cpu", "memory", "disk"],
+        measure = ["cpu", "memory", "disk"]
         result = logger._run(
             "sleep 1",
             measure=measure,
@@ -740,7 +740,7 @@ def test_log_book_trace_and_stats() -> None:
     """
     if os.uname().sysname == "Linux":
         logger = ShellLogger(stack()[0][3], Path.cwd())
-        measure = ["cpu", "memory", "disk"],
+        measure = ["cpu", "memory", "disk"]
         logger.log(
             "Sleep",
             "sleep 1",