Skip to content

Commit 99b2a5d

Browse files
authored
Log env packages (#62)
* Log python version * Log environment info * Parametrize header tests, add tests mocking environment, remove duplication when logging pkgs Add tests mocking environment, remove duplication writing packages * Add test to mock lack of any local environment
1 parent d359f5d commit 99b2a5d

File tree

2 files changed

+334
-0
lines changed

2 files changed

+334
-0
lines changed

fancylog/fancylog.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
"""Wrapper around the standard logging module, with additional information."""
22

33
import contextlib
4+
import json
45
import logging
56
import os
7+
import subprocess
68
import sys
79
from datetime import datetime
810
from importlib.util import find_spec
@@ -28,6 +30,8 @@ def start_logging(
2830
write_header=True,
2931
write_git=True,
3032
write_cli_args=True,
33+
write_python=True,
34+
write_env_packages=True,
3135
write_variables=True,
3236
log_to_file=True,
3337
log_to_console=True,
@@ -62,6 +66,10 @@ def start_logging(
6266
Write information about the git repository. Default: True
6367
write_cli_args
6468
Log the command-line arguments. Default: True
69+
write_python
70+
Log the Python version. Default: True
71+
write_env_packages
72+
Log the packages in the environment. Default: True
6573
write_variables
6674
Write the attributes of selected objects. Default: True
6775
log_to_file
@@ -105,6 +113,8 @@ def start_logging(
105113
write_header=write_header,
106114
write_git=write_git,
107115
write_cli_args=write_cli_args,
116+
write_python=write_python,
117+
write_env_packages=write_env_packages,
108118
write_variables=write_variables,
109119
log_header=log_header,
110120
)
@@ -132,6 +142,8 @@ def __init__(
132142
write_header=True,
133143
write_git=True,
134144
write_cli_args=True,
145+
write_python=True,
146+
write_env_packages=True,
135147
write_variables=True,
136148
log_header=None,
137149
):
@@ -148,6 +160,10 @@ def __init__(
148160
self.write_git_info(self.program.__name__)
149161
if write_cli_args:
150162
self.write_command_line_arguments()
163+
if write_python:
164+
self.write_python_version()
165+
if write_env_packages:
166+
self.write_environment_packages()
151167
if write_variables and variable_objects:
152168
self.write_variables(variable_objects)
153169

@@ -206,6 +222,105 @@ def write_command_line_arguments(self, header="COMMAND LINE ARGUMENTS"):
206222
self.file.write(f"Command: {sys.argv[0]} \n")
207223
self.file.write(f"Input arguments: {sys.argv[1:]}")
208224

225+
def write_python_version(self, header="PYTHON VERSION"):
226+
"""Write the Python version used to run the script.
227+
228+
Parameters
229+
----------
230+
header
231+
Title of the section that will be written to the log file.
232+
233+
"""
234+
self.write_separated_section_header(header)
235+
self.file.write(f"Python version: {sys.version.split()[0]}")
236+
237+
def write_environment_packages(self, header="ENVIRONMENT"):
238+
"""Write the local/global environment packages used to run the script.
239+
240+
Attempt to collect conda packages and, if this fails,
241+
collect pip packages.
242+
243+
Parameters
244+
----------
245+
header
246+
Title of the section that will be written to the log file
247+
248+
"""
249+
self.write_separated_section_header(header)
250+
251+
# Attempt to log conda env name and packages
252+
try:
253+
conda_env = os.environ["CONDA_PREFIX"].split(os.sep)[-1]
254+
conda_exe = os.environ["CONDA_EXE"]
255+
conda_list = subprocess.run(
256+
[conda_exe, "list", "--json"], capture_output=True, text=True
257+
)
258+
259+
env_pkgs = json.loads(conda_list.stdout)
260+
261+
self.file.write(f"Conda environment: {conda_env}\n\n")
262+
self.file.write("Environment packages (conda):\n")
263+
self.write_packages(env_pkgs)
264+
265+
# If no conda env, fall back to logging pip
266+
except KeyError:
267+
python_executable = sys.executable
268+
pip_list = subprocess.run(
269+
[
270+
python_executable,
271+
"-m",
272+
"pip",
273+
"list",
274+
"--verbose",
275+
"--format=json",
276+
],
277+
capture_output=True,
278+
text=True,
279+
)
280+
281+
all_pkgs = json.loads(pip_list.stdout)
282+
283+
try:
284+
# If there is a local env, log local packages first
285+
env_pkgs = [
286+
pkg
287+
for pkg in all_pkgs
288+
if os.getenv("VIRTUAL_ENV") in pkg["location"]
289+
]
290+
291+
self.file.write(
292+
"No conda environment found, reporting pip packages\n\n"
293+
)
294+
self.file.write("Local environment packages (pip):\n")
295+
self.write_packages(env_pkgs)
296+
self.file.write("\n")
297+
298+
# Log global-available packages (if any)
299+
global_pkgs = [pkg for pkg in all_pkgs if pkg not in env_pkgs]
300+
301+
self.file.write("Global environment packages (pip):\n")
302+
self.write_packages(global_pkgs)
303+
304+
except TypeError:
305+
self.file.write(
306+
"No environment found, reporting global pip packages\n\n"
307+
)
308+
self.write_packages(all_pkgs)
309+
310+
def write_packages(self, env_pkgs):
311+
"""Write the packages in the local environment.
312+
313+
Parameters
314+
----------
315+
env_pkgs
316+
A dictionary of environment packages, the name and version
317+
of which will be written.
318+
319+
"""
320+
self.file.write(f"{'Name':20} {'Version':15}\n")
321+
for pkg in env_pkgs:
322+
self.file.write(f"{pkg['name']:20} {pkg['version']:15}\n")
323+
209324
def write_variables(self, variable_objects):
210325
"""Write a section for variables with their values.
211326

tests/tests/test_general.py

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1+
import json
12
import logging
3+
import os
4+
import platform
5+
import subprocess
6+
import sys
7+
from importlib.metadata import distributions
8+
from unittest.mock import MagicMock, patch
29

310
import pytest
411
from rich.logging import RichHandler
@@ -206,3 +213,215 @@ def test_named_logger_doesnt_propagate(tmp_path, capsys):
206213
assert "PQ&*" not in captured.out, (
207214
"logger writing to stdout through root handler"
208215
)
216+
217+
218+
@pytest.mark.parametrize("boolean, operator", [(True, True), (False, False)])
219+
def test_python_version_header(boolean, operator, tmp_path):
220+
ver_header = f"{lateral_separator} PYTHON VERSION {lateral_separator}\n"
221+
222+
fancylog.start_logging(tmp_path, fancylog, write_python=boolean)
223+
224+
log_file = next(tmp_path.glob("*.log"))
225+
226+
with open(log_file) as file:
227+
assert (ver_header in file.read()) == operator
228+
229+
230+
def test_correct_python_version_logged(tmp_path):
231+
"""Python version logged should be equal to
232+
the output of platform.python_version().
233+
"""
234+
235+
fancylog.start_logging(tmp_path, fancylog, write_python=True)
236+
237+
log_file = next(tmp_path.glob("*.log"))
238+
239+
# Test logged python version is equal to platform.python_version()
240+
with open(log_file) as file:
241+
assert f"Python version: {platform.python_version()}" in file.read()
242+
243+
244+
@pytest.mark.parametrize("boolean, operator", [(True, True), (False, False)])
245+
def test_environment_header(boolean, operator, tmp_path):
246+
ver_header = f"{lateral_separator} ENVIRONMENT {lateral_separator}\n"
247+
248+
fancylog.start_logging(tmp_path, fancylog, write_env_packages=boolean)
249+
250+
log_file = next(tmp_path.glob("*.log"))
251+
252+
with open(log_file) as file:
253+
assert (ver_header in file.read()) == operator
254+
255+
256+
def test_correct_pkg_version_logged(tmp_path):
257+
"""Package versions logged should be equal to
258+
the output of `conda list` or `pip list`.
259+
"""
260+
fancylog.start_logging(tmp_path, fancylog, write_env_packages=True)
261+
262+
log_file = next(tmp_path.glob("*.log"))
263+
264+
try:
265+
# If there is a conda environment, assert that the correct
266+
# version is logged for all pkgs
267+
conda_exe = os.environ["CONDA_EXE"]
268+
conda_list = subprocess.run(
269+
[conda_exe, "list", "--json"], capture_output=True, text=True
270+
)
271+
272+
conda_pkgs = json.loads(conda_list.stdout)
273+
for pkg in conda_pkgs:
274+
assert f"{pkg['name']:20} {pkg['version']:15}\n"
275+
276+
except KeyError:
277+
# If there is no conda environment, assert that the correct
278+
# version is logged for all packages logged with pip list
279+
with open(log_file) as file:
280+
file_content = file.read()
281+
282+
# Test local environment versions
283+
local_site_packages = next(
284+
p for p in sys.path if "site-packages" in p
285+
)
286+
287+
for dist in distributions():
288+
if str(dist.locate_file("")).startswith(local_site_packages):
289+
assert (
290+
f"{dist.metadata['Name']:20} {dist.version}"
291+
in file_content
292+
)
293+
294+
295+
def test_mock_pip_pkgs(tmp_path):
296+
"""Mock pip list subprocess
297+
and test that packages are logged correctly.
298+
"""
299+
300+
# Simulated `pip list --json` output
301+
fake_pip_output = json.dumps(
302+
[
303+
{"name": "fancylog", "version": "1.1.1", "location": "fake_env"},
304+
{"name": "pytest", "version": "1.1.1", "location": "global_env"},
305+
]
306+
)
307+
308+
# Patch the environment and subprocess
309+
with (
310+
patch.dict(os.environ, {}, clear=False),
311+
patch("os.getenv") as mock_getenv,
312+
patch("subprocess.run") as mock_run,
313+
):
314+
# Eliminate conda environment packages triggers logging pip list
315+
os.environ.pop("CONDA_PREFIX", None)
316+
os.environ.pop("CONDA_EXE", None)
317+
318+
mock_getenv.return_value = "fake_env"
319+
320+
# Mocked subprocess result
321+
mock_run.return_value = MagicMock(stdout=fake_pip_output, returncode=0)
322+
323+
fancylog.start_logging(tmp_path, fancylog, write_env_packages=True)
324+
325+
log_file = next(tmp_path.glob("*.log"))
326+
327+
# Log contains conda subheaders and mocked pkgs versions
328+
with open(log_file) as file:
329+
file_content = file.read()
330+
331+
assert (
332+
"No conda environment found, reporting pip packages"
333+
) in file_content
334+
335+
assert f"{'fancylog':20} {'1.1.1'}"
336+
assert f"{'pytest':20} {'1.1.1'}"
337+
338+
339+
def test_mock_conda_pkgs(tmp_path):
340+
"""Mock conda environment variables
341+
and test that packages are logged correctly.
342+
"""
343+
344+
fake_conda_env_name = "test_env"
345+
fake_conda_prefix = os.path.join(
346+
"path", "conda", "envs", fake_conda_env_name
347+
)
348+
fake_conda_exe = os.path.join("fake", "conda")
349+
350+
# Simulated `conda list --json` output
351+
fake_conda_output = json.dumps(
352+
[
353+
{"name": "fancylog", "version": "1.1.1"},
354+
{"name": "pytest", "version": "1.1.1"},
355+
]
356+
)
357+
358+
# Patch the environment and subprocess
359+
with (
360+
patch.dict(
361+
os.environ,
362+
{"CONDA_PREFIX": fake_conda_prefix, "CONDA_EXE": fake_conda_exe},
363+
),
364+
patch("subprocess.run") as mock_run,
365+
):
366+
# Mocked subprocess result
367+
mock_run.return_value = MagicMock(
368+
stdout=fake_conda_output, returncode=0
369+
)
370+
371+
fancylog.start_logging(tmp_path, fancylog, write_env_packages=True)
372+
373+
log_file = next(tmp_path.glob("*.log"))
374+
375+
# Log contains conda subheaders and mocked pkgs versions
376+
with open(log_file) as file:
377+
file_content = file.read()
378+
379+
assert "Conda environment:" in file_content
380+
assert "Environment packages (conda):" in file_content
381+
assert f"{'fancylog':20} {'1.1.1'}"
382+
assert f"{'pytest':20} {'1.1.1'}"
383+
384+
385+
def test_mock_no_environment(tmp_path):
386+
"""Mock lack of any environment,
387+
and test that packages are logged correctly.
388+
"""
389+
390+
# Simulated `pip list --json` output
391+
fake_pip_output = json.dumps(
392+
[
393+
{"name": "fancylog", "version": "1.1.1", "location": "fake_env"},
394+
{"name": "pytest", "version": "1.1.1", "location": "global_env"},
395+
]
396+
)
397+
398+
# Patch the environment and subprocess
399+
with (
400+
patch.dict(os.environ, {}, clear=False),
401+
patch("os.getenv") as mock_getenv,
402+
patch("subprocess.run") as mock_run,
403+
):
404+
# Eliminate conda environment packages triggers logging pip list
405+
os.environ.pop("CONDA_PREFIX", None)
406+
os.environ.pop("CONDA_EXE", None)
407+
408+
# Mock lack of any local environment
409+
mock_getenv.return_value = None
410+
411+
# Mocked subprocess result
412+
mock_run.return_value = MagicMock(stdout=fake_pip_output, returncode=0)
413+
414+
fancylog.start_logging(tmp_path, fancylog, write_env_packages=True)
415+
416+
log_file = next(tmp_path.glob("*.log"))
417+
418+
# Log contains conda subheaders and mocked pkgs versions
419+
with open(log_file) as file:
420+
file_content = file.read()
421+
422+
assert (
423+
"No environment found, reporting global pip packages"
424+
) in file_content
425+
426+
assert f"{'fancylog':20} {'1.1.1'}"
427+
assert f"{'pytest':20} {'1.1.1'}"

0 commit comments

Comments
 (0)