diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 630704ccfe..ad66636595 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -86,6 +86,9 @@ Of interest to developers #. Added autogenerated documentation with sphinx and autodoc/autosummary [WIP] #. Made manim internally use relative imports #. Since the introduction of the :code:`TexTemplate` class, the files :code:`tex_template.tex` and :code:`ctex_template.tex` have been removed +#. Added logging tests tools. +#. Added ability to save logs in json + Other Changes diff --git a/manim/__main__.py b/manim/__main__.py index 14d524e060..3dc595983f 100644 --- a/manim/__main__.py +++ b/manim/__main__.py @@ -14,6 +14,7 @@ from .scene.scene import Scene from .utils.sounds import play_error_sound, play_finish_sound from .utils.file_ops import open_file as open_media_file +from . import constants def open_file_if_needed(file_writer): diff --git a/manim/config/config.py b/manim/config/config.py index c1f0936e59..c92e77e0be 100644 --- a/manim/config/config.py +++ b/manim/config/config.py @@ -18,9 +18,11 @@ from .. import constants from .config_utils import _run_config, _init_dirs, _from_command_line -from .logger import logger +from .logger import set_rich_logger, set_file_logger, logger from ..utils.tex import TexTemplate, TexTemplateFromFile +__all__ = ["file_writer_config", "config", "camera_config", "tempconfig"] + config = None @@ -150,3 +152,14 @@ def _parse_config(config_parser, args): _init_dirs(file_writer_config) config = _parse_config(config_parser, args) camera_config = config + +# Set the different loggers +set_rich_logger(config_parser["logger"]) +if file_writer_config["log_to_file"]: + # IMPORTANT note about file name : The log file name will be the scene_name get from the args (contained in file_writer_config). So it can differ from the real name of the scene. + log_file_path = os.path.join( + file_writer_config["log_dir"], + "".join(file_writer_config["scene_names"]) + ".log", + ) + set_file_logger(log_file_path) + logger.info("Log file wil be saved in %(logpath)s", {"logpath": log_file_path}) diff --git a/manim/config/config_utils.py b/manim/config/config_utils.py index 37b265032a..d264651ac3 100644 --- a/manim/config/config_utils.py +++ b/manim/config/config_utils.py @@ -69,9 +69,15 @@ def _parse_file_writer_config(config_parser, args): default.getboolean(boolean_opt) if attr is None else attr ) # for str_opt in ['media_dir', 'video_dir', 'tex_dir', 'text_dir']: - for str_opt in ["media_dir", "log_dir"]: + for str_opt in ["media_dir"]: attr = getattr(args, str_opt) fw_config[str_opt] = os.path.relpath(default[str_opt]) if attr is None else attr + attr = getattr(args, "log_dir") + fw_config["log_dir"] = ( + os.path.join(fw_config["media_dir"], default["log_dir"]) + if attr is None + else attr + ) dir_names = { "video_dir": "videos", "images_dir": "images", @@ -147,9 +153,7 @@ def _parse_file_writer_config(config_parser, args): } # For internal use (no CLI flag) - fw_config["skip_animations"] = any( - [fw_config["save_last_frame"], fw_config["from_animation_number"]] - ) + fw_config["skip_animations"] = fw_config["save_last_frame"] fw_config["max_files_cached"] = default.getint("max_files_cached") if fw_config["max_files_cached"] == -1: fw_config["max_files_cached"] = float("inf") diff --git a/manim/config/default.cfg b/manim/config/default.cfg index 0b4fb6096e..d864da45cb 100644 --- a/manim/config/default.cfg +++ b/manim/config/default.cfg @@ -75,8 +75,8 @@ upto_animation_number = -1 # as well as Tex and texts. media_dir = ./media -# --log_dir -log_dir = %(media_dir)s/logs +# --log_dir (by default "/logs", that will be put inside the media dir) +log_dir = logs # If the -t (--transparent) flag is used, these will be replaced with the # values specified in the [TRANSPARENT] section later in this file. diff --git a/manim/config/logger.py b/manim/config/logger.py index 143509b34b..364cbfe3f6 100644 --- a/manim/config/logger.py +++ b/manim/config/logger.py @@ -7,7 +7,7 @@ """ -__all__ = ['logger', 'console'] +__all__ = ["logger", "console"] import configparser @@ -18,16 +18,31 @@ from rich.theme import Theme from rich import print as printf from rich import errors, color +import json +import copy -from .config_utils import _run_config +class JSONFormatter(logging.Formatter): + """Subclass of `:class:`logging.Formatter`, to build our own format of the logs (JSON).""" -def parse_theme(fp): - config_parser.read(fp) - theme = dict(config_parser["logger"]) - # replaces `_` by `.` as rich understands it + def format(self, record): + record_c = copy.deepcopy(record) + if record_c.args: + for arg in record_c.args: + record_c.args[arg] = "<>" + return json.dumps({ + "levelname": record_c.levelname, + "module": record_c.module, + "message": super().format(record_c), + }) + + +def _parse_theme(config_logger): theme = dict( - zip([key.replace("_", ".") for key in theme.keys()], list(theme.values())) + zip( + [key.replace("_", ".") for key in config_logger.keys()], + list(config_logger.values()), + ) ) theme["log.width"] = None if theme["log.width"] == "-1" else int(theme["log.width"]) @@ -35,7 +50,7 @@ def parse_theme(fp): theme["log.height"] = ( None if theme["log.height"] == "-1" else int(theme["log.height"]) ) - theme["log.timestamps"] = config_parser["logger"].getboolean("log.timestamps") + theme["log.timestamps"] = False try: customTheme = Theme( { @@ -49,51 +64,55 @@ def parse_theme(fp): printf( "[logging.level.error]It seems your colour configuration couldn't be parsed. Loading the default color configuration...[/logging.level.error]" ) - return customTheme, theme - - -config_items = _run_config() -config_parser, successfully_read_files = config_items[1], config_items[-1] -try: - customTheme, themedict = parse_theme(successfully_read_files) - console = Console( - theme=customTheme, - record=True, - height=themedict["log.height"], - width=themedict["log.width"], - ) -except KeyError: - console = Console(record=True) - printf( - "[logging.level.warning]No cfg file found, creating one in " - + successfully_read_files[0] - + " [/logging.level.warning]" + return customTheme + + +def set_rich_logger(config_logger): + """Will set the RichHandler of the logger. + + Parameter + ---------- + config_logger :class: + Config object of the logger. + """ + theme = _parse_theme(config_logger) + global console + console = Console(theme=theme) + # These keywords Are Highlighted specially. + RichHandler.KEYWORDS = [ + "Played", + "animations", + "scene", + "Reading", + "Writing", + "script", + "arguments", + "Invalid", + "Aborting", + "module", + "File", + "Rendering", + "Rendered", + ] + rich_handler = RichHandler( + console=console, show_time=config_logger.getboolean("log_timestamps") ) + global logger + logger.addHandler(rich_handler) + + +def set_file_logger(log_file_path): + file_handler = logging.FileHandler(log_file_path, mode="w") + file_handler.setFormatter(JSONFormatter()) + # We set the level to DEBUG to it will be able to catch all logs. + file_handler.setLevel(logging.DEBUG) + global logger + logger.addHandler(file_handler) + -# These keywords Are Highlighted specially. -RichHandler.KEYWORDS = [ - "Played", - "animations", - "scene", - "Reading", - "Writing", - "script", - "arguments", - "Invalid", - "Aborting", - "module", - "File", - "Rendering", - "Rendered", -] -logging.basicConfig( - level="NOTSET", - format="%(message)s", - datefmt="[%X]", - handlers=[RichHandler(console=console, show_time=themedict["log.timestamps"])], -) - -logger = logging.getLogger("rich") +logger = logging.getLogger("manim") +# The console is set to None as it will be changed by set_rich_logger. +console = None # TODO : This is only temporary to keep the terminal output clean when working with ImageMobject and matplotlib plots logging.getLogger("PIL").setLevel(logging.INFO) diff --git a/manim/scene/scene.py b/manim/scene/scene.py index 1ca43e09a1..8e40d80b50 100644 --- a/manim/scene/scene.py +++ b/manim/scene/scene.py @@ -65,9 +65,10 @@ def construct(self): def __init__(self, **kwargs): Container.__init__(self, **kwargs) self.camera = self.camera_class(**camera_config) - self.file_writer = SceneFileWriter(self, **file_writer_config,) + self.file_writer = SceneFileWriter(self, **file_writer_config) self.play_hashes_list = [] self.mobjects = [] + self.original_skipping_status = file_writer_config["skip_animations"] # TODO, remove need for foreground mobjects self.foreground_mobjects = [] self.num_plays = 0 @@ -85,7 +86,7 @@ def __init__(self, **kwargs): self.tear_down() # We have to reset these settings in case of multiple renders. file_writer_config["skip_animations"] = False - self.original_skipping_status = file_writer_config["skip_animations"] + self.file_writer.finish() self.print_end_message() @@ -195,7 +196,7 @@ def update_frame( # TODO Description in Docstring if file_writer_config["skip_animations"] and not ignore_skipping: return if mobjects is None: - mobjects = list_update(self.mobjects, self.foreground_mobjects,) + mobjects = list_update(self.mobjects, self.foreground_mobjects) if background is not None: self.camera.set_pixel_array(background) else: @@ -779,10 +780,10 @@ def update_skipping_status(self): """ if file_writer_config["from_animation_number"]: - if self.num_plays == file_writer_config["from_animation_number"]: - file_writer_config["skip_animations"] = False + if self.num_plays < file_writer_config["from_animation_number"]: + file_writer_config["skip_animations"] = True if file_writer_config["upto_animation_number"]: - if self.num_plays >= file_writer_config["upto_animation_number"]: + if self.num_plays > file_writer_config["upto_animation_number"]: file_writer_config["skip_animations"] = True raise EndSceneEarlyException() @@ -800,8 +801,13 @@ def handle_caching_play(func): def wrapper(self, *args, **kwargs): self.revert_to_original_skipping_status() + self.update_skipping_status() animations = self.compile_play_args_to_animation_list(*args, **kwargs) self.add_mobjects_from_animations(animations) + if file_writer_config["skip_animations"]: + logger.debug(f"Skipping animation {self.num_plays}") + func(self, *args, **kwargs) + return if not file_writer_config["disable_caching"]: mobjects_on_scene = self.get_mobjects() hash_play = get_hash_from_play_call( @@ -810,7 +816,8 @@ def wrapper(self, *args, **kwargs): self.play_hashes_list.append(hash_play) if self.file_writer.is_already_cached(hash_play): logger.info( - f"Animation {self.num_plays} : Using cached data (hash : {hash_play})" + f"Animation {self.num_plays} : Using cached data (hash : %(hash_play)s)", + {"hash_play": hash_play}, ) file_writer_config["skip_animations"] = True else: @@ -834,6 +841,7 @@ def handle_caching_wait(func): def wrapper(self, duration=DEFAULT_WAIT_TIME, stop_condition=None): self.revert_to_original_skipping_status() + self.update_skipping_status() if not file_writer_config["disable_caching"]: hash_wait = get_hash_from_wait_call( self.camera, duration, stop_condition, self.get_mobjects() @@ -873,7 +881,6 @@ def handle_play_like_call(func): """ def wrapper(self, *args, **kwargs): - self.update_skipping_status() allow_write = not file_writer_config["skip_animations"] self.file_writer.begin_animation(allow_write) func(self, *args, **kwargs) diff --git a/manim/scene/scene_file_writer.py b/manim/scene/scene_file_writer.py index eb9e8b7289..d64b125046 100644 --- a/manim/scene/scene_file_writer.py +++ b/manim/scene/scene_file_writer.py @@ -44,6 +44,7 @@ def __init__(self, scene, **kwargs): self.init_output_directories() self.init_audio() self.frame_count = 0 + self.index_partial_movie_file = 0 # Output directories and files def init_output_directories(self): @@ -58,9 +59,7 @@ def init_output_directories(self): if file_writer_config["media_dir"] != "": if not file_writer_config["custom_folders"]: image_dir = guarantee_existence( - os.path.join( - file_writer_config["images_dir"], module_directory, - ) + os.path.join(file_writer_config["images_dir"], module_directory) ) else: image_dir = guarantee_existence(file_writer_config["images_dir"]) @@ -93,7 +92,7 @@ def init_output_directories(self): ) if not file_writer_config["custom_folders"]: self.partial_movie_directory = guarantee_existence( - os.path.join(movie_dir, "partial_movie_files", scene_name,) + os.path.join(movie_dir, "partial_movie_files", scene_name) ) else: self.partial_movie_directory = guarantee_existence( @@ -191,10 +190,11 @@ def get_next_partial_movie_path(self): result = os.path.join( self.partial_movie_directory, "{}{}".format( - self.scene.play_hashes_list[self.scene.num_plays], + self.scene.play_hashes_list[self.index_partial_movie_file], file_writer_config["movie_file_extension"], ), ) + self.index_partial_movie_file += 1 return result def get_movie_file_path(self): @@ -252,7 +252,7 @@ def add_audio_segment(self, new_segment, time=None, gain_to_background=None): diff = new_end - curr_end if diff > 0: segment = segment.append( - AudioSegment.silent(int(np.ceil(diff * 1000))), crossfade=0, + AudioSegment.silent(int(np.ceil(diff * 1000))), crossfade=0 ) self.audio_segment = segment.overlay( new_segment, @@ -443,9 +443,7 @@ def close_movie_pipe(self): """ self.writing_process.stdin.close() self.writing_process.wait() - shutil.move( - self.temp_partial_movie_file_path, self.partial_movie_file_path, - ) + shutil.move(self.temp_partial_movie_file_path, self.partial_movie_file_path) logger.debug( f"Animation {self.scene.num_plays} : Partial movie file written in {self.partial_movie_file_path}" ) @@ -535,9 +533,7 @@ def combine_movie_files(self): ) # Makes sure sound file length will match video file self.add_audio_segment(AudioSegment.silent(0)) - self.audio_segment.export( - sound_file_path, bitrate="312k", - ) + self.audio_segment.export(sound_file_path, bitrate="312k") temp_file_path = movie_file_path.replace(".", "_temp.") commands = [ FFMPEG_BIN, @@ -608,21 +604,12 @@ def flush_cache_directory(self): for f in cached_partial_movies: os.remove(f) logger.info( - f"Cache flushed. {len(cached_partial_movies)} file(s) deleted in {self.partial_movie_directory}." + f"Cache flushed. {len(cached_partial_movies)} file(s) deleted in %(par_dir)s.", + {"par_dir": self.partial_movie_directory}, ) def print_file_ready_message(self, file_path): """ Prints the "File Ready" message to STDOUT. """ - logger.info("\nFile ready at {}\n".format(file_path)) - - if file_writer_config["log_to_file"]: - self.write_log() - - def write_log(self): - log_file_path = os.path.join( - file_writer_config["log_dir"], f"{self.get_default_scene_name()}.log" - ) - console.save_text(log_file_path) - logger.info("Log written to {}\n".format(log_file_path)) + logger.info("\nFile ready at %(file_path)s\n", {"file_path": file_path}) diff --git a/tests/control_data/logs_data/BasicSceneLoggingTest.txt b/tests/control_data/logs_data/BasicSceneLoggingTest.txt new file mode 100644 index 0000000000..5354d5fd1c --- /dev/null +++ b/tests/control_data/logs_data/BasicSceneLoggingTest.txt @@ -0,0 +1,3 @@ +{"levelname": "INFO", "module": "config", "message": "Log file wil be saved in <>"} +{"levelname": "INFO", "module": "scene_file_writer", "message": "\nFile ready at <>\n"} +{"levelname": "INFO", "module": "scene", "message": "Rendered SquareToCircle\nPlayed 1 animations"} diff --git a/tests/control_data/videos_data/SceneWithMultipleCallsWithNFlag.json b/tests/control_data/videos_data/SceneWithMultipleCallsWithNFlag.json new file mode 100644 index 0000000000..2e125f125c --- /dev/null +++ b/tests/control_data/videos_data/SceneWithMultipleCallsWithNFlag.json @@ -0,0 +1,11 @@ +{ + "name": "SceneWithMultipleCallsWithNFlag", + "config": { + "codec_name": "h264", + "width": 1920, + "height": 1080, + "avg_frame_rate": "60/1", + "duration": "4.000000", + "nb_frames": "240" + } +} \ No newline at end of file diff --git a/tests/helpers/video_utils.py b/tests/helpers/video_utils.py index 4d3058b1cd..a59f3fa9ec 100644 --- a/tests/helpers/video_utils.py +++ b/tests/helpers/video_utils.py @@ -4,7 +4,7 @@ import subprocess import json -from manim.logger import logger +from manim.config.logger import logger def capture(command): @@ -57,3 +57,9 @@ def save_control_data_from_video(path_to_video, name): path_saved = os.path.join(path_control_data, f"{name}.json") json.dump(data, open(path_saved, "w"), indent=4) logger.info(f"Data for {name} saved in {path_saved}") + + +save_control_data_from_video( + r"/home/hugues/Desktop/Programmation/PYTHON/MANIM-DEV/media/videos/test/1080p60/SceneWithMultipleCalls.mp4", + "SceneWithMultipleCallsWithNFlag", +) diff --git a/tests/test_logging/__init__.py b/tests/test_logging/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/test_logging/basic_scenes.py b/tests/test_logging/basic_scenes.py index 09172c3f7b..3f190f36e8 100644 --- a/tests/test_logging/basic_scenes.py +++ b/tests/test_logging/basic_scenes.py @@ -5,15 +5,7 @@ class SquareToCircle(Scene): def construct(self): - circle = Circle() - square = Square() - square.flip(RIGHT) - square.rotate(-3 * TAU / 8) - circle.set_fill(PINK, opacity=0.5) - - self.play(ShowCreation(square)) - self.play(Transform(square, circle)) - self.play(FadeOut(square)) + self.play(Transform(Square(), Circle())) class WriteStuff(Scene): diff --git a/tests/test_logging/expected.txt b/tests/test_logging/expected.txt deleted file mode 100644 index f0920138e3..0000000000 --- a/tests/test_logging/expected.txt +++ /dev/null @@ -1,7 +0,0 @@ -DEBUG Read configuration files: config.py: -DEBUG Animation : Partial movie file written in scene_file_writer.py: -DEBUG Animation : Partial movie file written in scene_file_writer.py: -DEBUG Animation : Partial movie file written in scene_file_writer.py: -INFO scene_file_writer.py: - File ready at - diff --git a/tests/test_logging/test_logging.py b/tests/test_logging/test_logging.py index 7f188d018a..9d9fd64073 100644 --- a/tests/test_logging/test_logging.py +++ b/tests/test_logging/test_logging.py @@ -4,65 +4,25 @@ import pytest import re - -def capture(command, instream=None, use_shell=False): - proc = subprocess.Popen( - command, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - stdin=instream, - shell=use_shell, - ) - out, err = proc.communicate() - return out, err, proc.returncode +from ..utils.commands import capture +from ..utils.logging_tester import * +@logs_comparison( + "BasicSceneLoggingTest.txt", os.path.join("logs", "SquareToCircle.log") +) def test_logging_to_file(tmp_path, python_version): - """Test logging Terminal output to a log file. - As some data will differ with each log (the timestamps, file paths, line nums etc) - a regex substitution has been employed to replace the strings that may change with - whitespace. - """ path_basic_scene = os.path.join("tests", "test_logging", "basic_scenes.py") - path_output = os.path.join(tmp_path, "media_temp") - os.makedirs(tmp_path, exist_ok=True) - command = " ".join( - [ - python_version, - "-m", - "manim", - path_basic_scene, - "SquareToCircle", - "-l", - "--log_to_file", - "--log_dir", - os.path.join(path_output, "logs"), - "--media_dir", - path_output, - "-v", - "DEBUG", - "--config_file", - os.path.join("tests", "test_logging", "testloggingconfig.cfg"), - ] - ) - out, err, exitcode = capture(command, use_shell=True) - log_file_path = os.path.join(path_output, "logs", "SquareToCircle.log") + command = [ + python_version, + "-m", + "manim", + path_basic_scene, + "SquareToCircle", + "-l", + "--log_to_file", + "--media_dir", + str(tmp_path), + ] + _, err, exitcode = capture(command) assert exitcode == 0, err.decode() - assert os.path.exists(log_file_path), err.decode() - if sys.platform.startswith("win32") or sys.platform.startswith("cygwin"): - enc = "Windows-1252" - else: - enc = "utf-8" - with open(log_file_path, encoding=enc) as logfile: - logs = logfile.read() - # The following regex pattern selects file paths and all numbers. - pattern = r"(\['[A-Z]?:?[\/\\].*cfg'])|([A-Z]?:?[\/\\].*mp4)|(\d+)" - - logs = re.sub(pattern, lambda m: " " * len((m.group(0))), logs) - with open( - os.path.join(os.path.dirname(__file__), "expected.txt"), "r" - ) as expectedfile: - expected = re.sub( - pattern, lambda m: " " * len((m.group(0))), expectedfile.read() - ) - assert logs == expected, logs diff --git a/tests/test_logging/testloggingconfig.cfg b/tests/test_logging/testloggingconfig.cfg deleted file mode 100644 index 069801255e..0000000000 --- a/tests/test_logging/testloggingconfig.cfg +++ /dev/null @@ -1,11 +0,0 @@ -[CLI] -movie_file_extension = .mp4 -write_to_movie = True -# write_all = False -save_last_frame = False -# save_pngs = False -verbosity = DEBUG - -[logger] -log_width = 512 -log_timestamps = False diff --git a/tests/test_scene_rendering/simple_scenes.py b/tests/test_scene_rendering/simple_scenes.py index 1b65ea1173..5bb905e8a4 100644 --- a/tests/test_scene_rendering/simple_scenes.py +++ b/tests/test_scene_rendering/simple_scenes.py @@ -6,3 +6,12 @@ def construct(self): square = Square() circle = Circle() self.play(Transform(square, circle)) + + +class SceneWithMultipleCalls(Scene): + def construct(self): + number = Integer(0) + self.add(number) + for i in range(10): + number.become(Integer(i)) + self.play(Animation(number)) \ No newline at end of file diff --git a/tests/utils/logging_tester.py b/tests/utils/logging_tester.py new file mode 100644 index 0000000000..051ac41d0e --- /dev/null +++ b/tests/utils/logging_tester.py @@ -0,0 +1,88 @@ +from functools import wraps +import os +import json +import itertools + + +def _check_logs(reference_logfile, generated_logfile): + with open(reference_logfile, "r") as reference_logs, open( + generated_logfile, "r" + ) as generated_logs: + reference_logs = reference_logs.readlines() + generated_logs = generated_logs.readlines() + diff = abs(len(reference_logs) - len(generated_logs)) + if len(reference_logs) != len(generated_logs): + msg_assert = "" + if len(reference_logs) > len(generated_logs): + msg_assert += f"Logs generated are SHORTER than the expected logs. There are {diff} extra logs.\n" + msg_assert += "Last log of the generated log is : \n" + msg_assert += generated_logs[-1] + else: + msg_assert += f"Logs generated are LONGER than the expected logs.\n There are {diff} extra logs :\n" + for log in generated_logs[len(reference_logs) :]: + msg_assert += log + assert 0, msg_assert + + for index, ref, gen in zip(itertools.count(), reference_logs, generated_logs): + # As they are string, we only need to check if they are equal. If they are not, we then compute a more precise difference, to debug. + if ref == gen: + continue + ref_log = json.loads(ref) + gen_log = json.loads(gen) + diff_keys = [ + d1[0] for d1, d2 in zip(ref_log.items(), gen_log.items()) if d1[1] != d2[1] + ] + # \n and \t don't not work in f-strings. + newline = "\n" + tab = "\t" + assert ( + len(diff_keys) == 0 + ), f"Lgos don't match at {index} st log. : \n{newline.join([f'In {key} field, got -> {newline}{tab}{repr(gen_log[key])}. {newline}Expected : -> {newline}{tab}{repr(ref_log[key])}.' for key in diff_keys])}" + + +def logs_comparison(control_data_file, log_path_from_media_dir): + """Decorator used for any test that needs to check logs. + + Parameters + ---------- + control_data_file : :class:`str` + Name of the control data file, i.e. .log that will be compared to the outputed logs. + .. warning:: You don't have to pass the path here. + .. example:: "SquareToCircleWithLFlag.log" + + log_path_from_media_dir : :class:`str` + The path of the .log generated, from the media dir. Example: /logs/Square.log. + + Returns + ------- + Callable[[Any], Any] + The test wrapped with which we are going to make the comparison. + """ + + def decorator(f): + @wraps(f) + def wrapper(*args, **kwargs): + # NOTE : Every args goes seemingly in kwargs instead of args; this is perhaps Pytest. + result = f(*args, **kwargs) + tmp_path = kwargs["tmp_path"] + tests_directory = os.path.dirname( + os.path.dirname(os.path.abspath(__file__)) + ) + controle_data_path = os.path.join( + tests_directory, "control_data", "logs_data", control_data_file + ) + path_log_generated = tmp_path / log_path_from_media_dir + # The following will say precisely which subdir does not exist. + if not os.path.exists(path_log_generated): + for parent in reversed(path_log_generated.parents): + if not parent.exists(): + assert ( + False + ), f"'{parent.name}' does not exist in '{parent.parent}' (which exists). " + break + _check_logs(controle_data_path, str(path_log_generated)) + return result + + return wrapper + + return decorator