From 93e57d746c8c3ac73a75091075c910b5815451f0 Mon Sep 17 00:00:00 2001 From: bkfox Date: Wed, 21 Jun 2023 22:33:37 +0200 Subject: [PATCH] write sound monitor tests --- aircox/controllers/sound_monitor.py | 52 +++--- aircox/test.py | 100 +++++++---- .../tests/controllers/test_sound_monitor.py | 159 +++++++++++++++--- 3 files changed, 238 insertions(+), 73 deletions(-) diff --git a/aircox/controllers/sound_monitor.py b/aircox/controllers/sound_monitor.py index ba66420..80a00cf 100644 --- a/aircox/controllers/sound_monitor.py +++ b/aircox/controllers/sound_monitor.py @@ -24,7 +24,7 @@ parameters given by the setting SOUND_QUALITY. This script requires Sox (and soxi). """ import atexit -import concurrent.futures as futures +from concurrent import futures import logging import time import os @@ -142,9 +142,9 @@ class MonitorHandler(PatternMatchingEventHandler): """ pool = None - jobs = {} + jobs = None - def __init__(self, subdir, pool, **sync_kw): + def __init__(self, subdir, pool, jobs=None, **sync_kw): """ :param str subdir: sub-directory in program dirs to monitor \ (SOUND_ARCHIVES_SUBDIR or SOUND_EXCERPTS_SUBDIR); @@ -154,6 +154,7 @@ class MonitorHandler(PatternMatchingEventHandler): """ self.subdir = subdir self.pool = pool + self.jobs = jobs or {} self.sync_kw = sync_kw patterns = [ @@ -199,29 +200,27 @@ class MonitorHandler(PatternMatchingEventHandler): class SoundMonitor: """Monitor for filesystem changes in order to synchronise database and - analyse files.""" + analyse files of a provided program.""" - def report(self, program=None, component=None, logger=logging, *content): - if not component: - logger.info( - "%s: %s", str(program), " ".join([str(c) for c in content]) - ) - else: - logger.info( - "%s, %s: %s", - str(program), - str(component), - " ".join([str(c) for c in content]), - ) + def report(self, program=None, component=None, *content, logger=logging): + content = " ".join([str(c) for c in content]) + logger.info( + f"{program}: {content}" + if not component + else f"{program}, {component}: {content}" + ) def scan(self, logger=logging): - """For all programs, scan dirs.""" + """For all programs, scan dirs. + + Return scanned directories. + """ logger.info("scan all programs...") programs = Program.objects.filter() dirs = [] for program in programs: - logger.info("#%d %s", program.id, program.title) + logger.info(f"#{program.id} {program.title}") self.scan_for_program( program, settings.SOUND_ARCHIVES_SUBDIR, @@ -234,7 +233,7 @@ class SoundMonitor: logger=logger, type=Sound.TYPE_EXCERPT, ) - dirs.append(os.path.join(program.abspath)) + dirs.append(program.abspath) return dirs def scan_for_program( @@ -272,7 +271,12 @@ class SoundMonitor: if sound.check_on_file(): SoundFile(sound.file.path).sync(sound=sound, **sync_kwargs) + _running = False + def monitor(self, logger=logging): + if self._running: + raise RuntimeError("already running") + """Run in monitor mode.""" with futures.ThreadPoolExecutor() as pool: archives_handler = MonitorHandler( @@ -307,5 +311,13 @@ class SoundMonitor: atexit.register(leave) - while True: + self._running = True + while self._running: time.sleep(1) + + leave() + atexit.unregister(leave) + + def stop(self): + """Stop monitor() loop.""" + self._running = False diff --git a/aircox/test.py b/aircox/test.py index b230b14..c67f2f3 100644 --- a/aircox/test.py +++ b/aircox/test.py @@ -44,38 +44,64 @@ InterfaceTarget = namedtuple( class WrapperMixin: - def __init__(self, target=None, ns=None, ns_attr=None, **kwargs): + type_interface = None + """For instance of class wrapped by an Interface, this is the wrapping + interface of the class.""" + instances = None + ns = None + ns_attr = None + + def __init__( + self, target=None, ns=None, ns_attr=None, type_interface=None, **kwargs + ): self.target = target if ns: self.inject(ns, ns_attr) + if self.type_interface: + self._set_type_interface(type_interface) super().__init__(**kwargs) + def _set_type_interface(self, type_interface): + if self.type_interface: + raise RuntimeError("a type interface is already assigned") + + self.type_interface = type_interface + if not type_interface.instances: + type_interface.instances = [self] + else: + type_interface.instances.append(self) + @property def ns_target(self): + """Actual namespace's target (using ns.ns_attr)""" if self.ns and self.ns_attr: return getattr(self.ns, self.ns_attr, None) return None def inject(self, ns=None, ns_attr=None): - if ns and ns_attr: - ns_target = getattr(ns, ns_attr, None) - if self.target is ns_target: - return - elif self.target is not None: - raise RuntimeError( - "self target already injected. It must be " - "`release` before `inject`." - ) - self.target = ns_target - setattr(ns, ns_attr, self.parent) - elif not ns or not ns_attr: + """Inject interface into namespace at given key.""" + if not (ns and ns_attr): raise ValueError("ns and ns_attr must be provided together") + + ns_target = getattr(ns, ns_attr, None) + if self.target is ns_target: + return + elif self.target is not None: + raise RuntimeError( + "self target already injected. It must be " + "`release` before `inject`." + ) + + self.target = ns_target + setattr(ns, ns_attr, self.interface) + self.ns = ns self.ns_attr = ns_attr def release(self): - if self.ns_target is self: - setattr(self.target.namespace, self.target.name, self.target) + """Remove injection from previously injected parent, reset target.""" + if self.ns_target is self.interface: + setattr(self.ns, self.ns_attr, self.target) self.target = None @@ -83,7 +109,9 @@ class SpoofMixin: traces = None def __init__(self, funcs=None, **kwargs): - self.reset(funcs or {}) + self.reset( + funcs or {}, + ) super().__init__(**kwargs) def reset(self, funcs=None): @@ -152,23 +180,19 @@ class SpoofMixin: """ func = self.funcs[name] if callable(func): - return func(*a, **kw) + return func(self, *a, **kw) return func -class InterfaceMeta(SpoofMixin, WrapperMixin): - calls = None - """Calls done.""" - - def __init__(self, parent, **kwargs): - self.parent = parent - super().__init__(**kwargs) - - def __getitem__(self, name): - return self.traces[name] - - class Interface: + class IMeta(SpoofMixin, WrapperMixin): + def __init__(self, interface, **kwargs): + self.interface = interface + super().__init__(**kwargs) + + def __getitem__(self, name): + return self.traces[name] + _imeta = None """This contains a InterfaceMeta instance related to Interface one. @@ -182,7 +206,7 @@ class Interface: _imeta_kw.setdefault("funcs", _funcs) if _target is not None: _imeta_kw.setdefault("target", _target) - self._imeta = InterfaceMeta(self, **_imeta_kw) + self._imeta = self.IMeta(self, **_imeta_kw) self.__dict__.update(kwargs) @property @@ -195,19 +219,29 @@ class Interface: return cls(**kwargs) def _irelease(self): + """Shortcut to `self._imeta.release`.""" self._imeta.release() def _trace(self, *args, **kw): + """Shortcut to `self._imeta.get_trace`.""" return self._imeta.get_trace(*args, **kw) def _traces(self, *args, **kw): + """Shortcut to `self._imeta.get_traces`.""" return self._imeta.get_traces(*args, **kw) def __call__(self, *args, **kwargs): target = self._imeta.target + print("is it class?", self, target, inspect.isclass(target)) if inspect.isclass(target): target = target(*args, **kwargs) - return type(self)(target, _imeta_kw={"funcs": self._imeta.funcs}) + return type(self)( + target, + _imeta_kw={"type_interface": self, "funcs": self._imeta.funcs}, + ) + + if "__call__" in self._imeta.funcs: + return self._imeta.call("__call__", args, kwargs) self._imeta.add("__call__", args, kwargs) return self._imeta.target(*args, **kwargs) @@ -216,3 +250,7 @@ class Interface: if attr in self._imeta.funcs: return lambda *args, **kwargs: self._imeta.call(attr, args, kwargs) return getattr(self._imeta.target, attr) + + def __str__(self): + iface = super().__str__() + return f"{iface}::{self._imeta.target}" diff --git a/aircox/tests/controllers/test_sound_monitor.py b/aircox/tests/controllers/test_sound_monitor.py index 543cfea..b792346 100644 --- a/aircox/tests/controllers/test_sound_monitor.py +++ b/aircox/tests/controllers/test_sound_monitor.py @@ -1,8 +1,10 @@ +from concurrent import futures import logging import pytest from django.utils import timezone as tz +from aircox.conf import settings from aircox.models import Sound from aircox.controllers import sound_monitor from aircox.test import Interface, interface @@ -25,8 +27,8 @@ def logger(): @pytest.fixture -def interfaces(logger): - return { +def interfaces(): + items = { "SoundFile": Interface.inject( sound_monitor, "SoundFile", @@ -41,8 +43,11 @@ def interfaces(logger): "sleep": None, }, ), - "datetime": Interface.inject(sound_monitor, "datetime", {now: now}), + "datetime": Interface.inject(sound_monitor, "datetime", {"now": now}), } + yield items + for item in items.values(): + item._irelease() @pytest.fixture @@ -65,6 +70,23 @@ def modified_task(interfaces): return sound_monitor.ModifiedTask() +@pytest.fixture +def monitor_handler(interfaces): + pool = Interface( + None, + { + "submit": lambda imeta, *a, **kw: Interface( + None, + { + "add_done_callback": None, + "done": False, + }, + ) + }, + ) + return sound_monitor.MonitorHandler("/tmp", pool=pool, sync_kw=13) + + class TestTask: def test___init__(self, task): assert task.timestamp is not None @@ -111,7 +133,7 @@ class TestModifiedTask: dt_now = now + modified_task.timeout_delta - tz.timedelta(hours=10) datetime = Interface.inject(sound_monitor, "datetime", {"now": dt_now}) - def sleep(n): + def sleep(imeta, n): datetime._imeta.funcs[ "now" ] = modified_task.timestamp + tz.timedelta(hours=10) @@ -129,31 +151,124 @@ class TestModifiedTask: class TestMonitorHandler: - def test_monitor___init__(self): - pass + def test_on_created(self, monitor_handler, event): + monitor_handler._submit = monitor_handler.pool.submit + monitor_handler.on_created(event) + trace_args, trace_kwargs = monitor_handler.pool._trace("submit") + assert isinstance(trace_args[0], sound_monitor.CreateTask) + assert trace_args[1:] == (event, "new") + assert trace_kwargs == monitor_handler.sync_kw - def test_on_created(self): - pass + def test_on_deleted(self, monitor_handler, event): + monitor_handler._submit = monitor_handler.pool.submit + monitor_handler.on_deleted(event) + trace_args, _ = monitor_handler.pool._trace("submit") + assert isinstance(trace_args[0], sound_monitor.DeleteTask) + assert trace_args[1:] == (event, "del") - def test_on_deleted(self): - pass + def test_on_moved(self, monitor_handler, event): + monitor_handler._submit = monitor_handler.pool.submit + monitor_handler.on_moved(event) + trace_args, trace_kwargs = monitor_handler.pool._trace("submit") + assert isinstance(trace_args[0], sound_monitor.MoveTask) + assert trace_args[1:] == (event, "mv") + assert trace_kwargs == monitor_handler.sync_kw - def test_on_moved(self): - pass + def test_on_modified(self, monitor_handler, event): + monitor_handler._submit = monitor_handler.pool.submit + monitor_handler.on_modified(event) + trace_args, trace_kwargs = monitor_handler.pool._trace("submit") + assert isinstance(trace_args[0], sound_monitor.ModifiedTask) + assert trace_args[1:] == (event, "up") + assert trace_kwargs == monitor_handler.sync_kw - def test_on_modified(self): - pass + def test__submit(self, monitor_handler, event): + handler = Interface() + handler, created = monitor_handler._submit( + handler, event, "prefix", kw=13 + ) + assert created + assert handler.future._trace("add_done_callback") + assert monitor_handler.pool._trace("submit") == ( + (handler, event), + {"kw": 13}, + ) - def test__submit(self): - pass + key = f"prefix:{event.src_path}" + assert monitor_handler.jobs.get(key) == handler + + +@pytest.fixture +def monitor_interfaces(): + items = { + "atexit": Interface.inject( + sound_monitor, "atexit", {"register": None, "leave": None} + ), + "observer": Interface.inject( + sound_monitor, + "Observer", + { + "schedule": None, + "start": None, + }, + ), + } + yield items + for item in items.values(): + item.release() + + +@pytest.fixture +def monitor(): + yield sound_monitor.SoundMonitor() class SoundMonitor: - def test_report(self): - pass + def test_report(self, monitor, program, logger): + monitor.report(program, "component", "content", logger=logger) + msg = f"{program}, component: content" + assert logger._trace("info", args=True) == (msg,) - def test_scan(self): - pass + def test_scan(self, monitor, program, logger): + interface = Interface(None, {"scan_for_program": None}) + monitor.scan_for_program = interface.scan_for_program + dirs = monitor.scan(logger) - def test_monitor(self): - pass + assert logger._traces("info") == ( + "scan all programs...", + f"#{program.id} {program.title}", + ) + assert dirs == [program.abspath] + assert interface._traces("scan_for_program") == ( + ((program, settings.SOUND_ARCHIVES_SUBDIR), {"logger": logger})( + (program, settings.SOUND_EXCERPTS_SUBDIR), {"logger": logger} + ) + ) + + def test_monitor(self, monitor, monitor_interfaces, logger): + def sleep(*args, **kwargs): + monitor.stop() + + time = Interface.inject(sound_monitor, "time", {"sleep": sleep}) + monitor.monitor(logger=logger) + time._irelease() + + observers = monitor_interfaces["observer"].instances + observer = observers and observers[0] + assert observer + schedules = observer._traces("schedule") + for (handler, *_), kwargs in schedules: + assert isinstance(handler, sound_monitor.MonitorHandler) + assert isinstance(handler.pool, futures.ThreadPoolExecutor) + assert (handler.subdir, handler.type) in ( + (settings.SOUND_ARCHIVES_SUBDIR, Sound.TYPE_ARCHIVE), + (settings.SOUND_EXCERPTS_SUBDIR, Sound.TYPE_EXCERPT), + ) + + assert observer._trace("start") + + atexit = monitor_interfaces["atexit"] + assert atexit._trace("register") + assert atexit._trace("unregister") + + assert observers