write sound monitor tests

This commit is contained in:
bkfox 2023-06-21 22:33:37 +02:00
parent d15ca98447
commit 93e57d746c
3 changed files with 238 additions and 73 deletions

View File

@ -24,7 +24,7 @@ parameters given by the setting SOUND_QUALITY. This script requires
Sox (and soxi). Sox (and soxi).
""" """
import atexit import atexit
import concurrent.futures as futures from concurrent import futures
import logging import logging
import time import time
import os import os
@ -142,9 +142,9 @@ class MonitorHandler(PatternMatchingEventHandler):
""" """
pool = None 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 \ :param str subdir: sub-directory in program dirs to monitor \
(SOUND_ARCHIVES_SUBDIR or SOUND_EXCERPTS_SUBDIR); (SOUND_ARCHIVES_SUBDIR or SOUND_EXCERPTS_SUBDIR);
@ -154,6 +154,7 @@ class MonitorHandler(PatternMatchingEventHandler):
""" """
self.subdir = subdir self.subdir = subdir
self.pool = pool self.pool = pool
self.jobs = jobs or {}
self.sync_kw = sync_kw self.sync_kw = sync_kw
patterns = [ patterns = [
@ -199,29 +200,27 @@ class MonitorHandler(PatternMatchingEventHandler):
class SoundMonitor: class SoundMonitor:
"""Monitor for filesystem changes in order to synchronise database and """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): def report(self, program=None, component=None, *content, logger=logging):
if not component: content = " ".join([str(c) for c in content])
logger.info( logger.info(
"%s: %s", str(program), " ".join([str(c) for c in content]) f"{program}: {content}"
) if not component
else: else f"{program}, {component}: {content}"
logger.info( )
"%s, %s: %s",
str(program),
str(component),
" ".join([str(c) for c in content]),
)
def scan(self, logger=logging): def scan(self, logger=logging):
"""For all programs, scan dirs.""" """For all programs, scan dirs.
Return scanned directories.
"""
logger.info("scan all programs...") logger.info("scan all programs...")
programs = Program.objects.filter() programs = Program.objects.filter()
dirs = [] dirs = []
for program in programs: for program in programs:
logger.info("#%d %s", program.id, program.title) logger.info(f"#{program.id} {program.title}")
self.scan_for_program( self.scan_for_program(
program, program,
settings.SOUND_ARCHIVES_SUBDIR, settings.SOUND_ARCHIVES_SUBDIR,
@ -234,7 +233,7 @@ class SoundMonitor:
logger=logger, logger=logger,
type=Sound.TYPE_EXCERPT, type=Sound.TYPE_EXCERPT,
) )
dirs.append(os.path.join(program.abspath)) dirs.append(program.abspath)
return dirs return dirs
def scan_for_program( def scan_for_program(
@ -272,7 +271,12 @@ class SoundMonitor:
if sound.check_on_file(): if sound.check_on_file():
SoundFile(sound.file.path).sync(sound=sound, **sync_kwargs) SoundFile(sound.file.path).sync(sound=sound, **sync_kwargs)
_running = False
def monitor(self, logger=logging): def monitor(self, logger=logging):
if self._running:
raise RuntimeError("already running")
"""Run in monitor mode.""" """Run in monitor mode."""
with futures.ThreadPoolExecutor() as pool: with futures.ThreadPoolExecutor() as pool:
archives_handler = MonitorHandler( archives_handler = MonitorHandler(
@ -307,5 +311,13 @@ class SoundMonitor:
atexit.register(leave) atexit.register(leave)
while True: self._running = True
while self._running:
time.sleep(1) time.sleep(1)
leave()
atexit.unregister(leave)
def stop(self):
"""Stop monitor() loop."""
self._running = False

View File

@ -44,38 +44,64 @@ InterfaceTarget = namedtuple(
class WrapperMixin: 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 self.target = target
if ns: if ns:
self.inject(ns, ns_attr) self.inject(ns, ns_attr)
if self.type_interface:
self._set_type_interface(type_interface)
super().__init__(**kwargs) 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 @property
def ns_target(self): def ns_target(self):
"""Actual namespace's target (using ns.ns_attr)"""
if self.ns and self.ns_attr: if self.ns and self.ns_attr:
return getattr(self.ns, self.ns_attr, None) return getattr(self.ns, self.ns_attr, None)
return None return None
def inject(self, ns=None, ns_attr=None): def inject(self, ns=None, ns_attr=None):
if ns and ns_attr: """Inject interface into namespace at given key."""
ns_target = getattr(ns, ns_attr, None) if not (ns and ns_attr):
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:
raise ValueError("ns and ns_attr must be provided together") 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 = ns
self.ns_attr = ns_attr self.ns_attr = ns_attr
def release(self): def release(self):
if self.ns_target is self: """Remove injection from previously injected parent, reset target."""
setattr(self.target.namespace, self.target.name, self.target) if self.ns_target is self.interface:
setattr(self.ns, self.ns_attr, self.target)
self.target = None self.target = None
@ -83,7 +109,9 @@ class SpoofMixin:
traces = None traces = None
def __init__(self, funcs=None, **kwargs): def __init__(self, funcs=None, **kwargs):
self.reset(funcs or {}) self.reset(
funcs or {},
)
super().__init__(**kwargs) super().__init__(**kwargs)
def reset(self, funcs=None): def reset(self, funcs=None):
@ -152,23 +180,19 @@ class SpoofMixin:
""" """
func = self.funcs[name] func = self.funcs[name]
if callable(func): if callable(func):
return func(*a, **kw) return func(self, *a, **kw)
return func 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 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 _imeta = None
"""This contains a InterfaceMeta instance related to Interface one. """This contains a InterfaceMeta instance related to Interface one.
@ -182,7 +206,7 @@ class Interface:
_imeta_kw.setdefault("funcs", _funcs) _imeta_kw.setdefault("funcs", _funcs)
if _target is not None: if _target is not None:
_imeta_kw.setdefault("target", _target) _imeta_kw.setdefault("target", _target)
self._imeta = InterfaceMeta(self, **_imeta_kw) self._imeta = self.IMeta(self, **_imeta_kw)
self.__dict__.update(kwargs) self.__dict__.update(kwargs)
@property @property
@ -195,19 +219,29 @@ class Interface:
return cls(**kwargs) return cls(**kwargs)
def _irelease(self): def _irelease(self):
"""Shortcut to `self._imeta.release`."""
self._imeta.release() self._imeta.release()
def _trace(self, *args, **kw): def _trace(self, *args, **kw):
"""Shortcut to `self._imeta.get_trace`."""
return self._imeta.get_trace(*args, **kw) return self._imeta.get_trace(*args, **kw)
def _traces(self, *args, **kw): def _traces(self, *args, **kw):
"""Shortcut to `self._imeta.get_traces`."""
return self._imeta.get_traces(*args, **kw) return self._imeta.get_traces(*args, **kw)
def __call__(self, *args, **kwargs): def __call__(self, *args, **kwargs):
target = self._imeta.target target = self._imeta.target
print("is it class?", self, target, inspect.isclass(target))
if inspect.isclass(target): if inspect.isclass(target):
target = target(*args, **kwargs) 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) self._imeta.add("__call__", args, kwargs)
return self._imeta.target(*args, **kwargs) return self._imeta.target(*args, **kwargs)
@ -216,3 +250,7 @@ class Interface:
if attr in self._imeta.funcs: if attr in self._imeta.funcs:
return lambda *args, **kwargs: self._imeta.call(attr, args, kwargs) return lambda *args, **kwargs: self._imeta.call(attr, args, kwargs)
return getattr(self._imeta.target, attr) return getattr(self._imeta.target, attr)
def __str__(self):
iface = super().__str__()
return f"{iface}::{self._imeta.target}"

View File

@ -1,8 +1,10 @@
from concurrent import futures
import logging import logging
import pytest import pytest
from django.utils import timezone as tz from django.utils import timezone as tz
from aircox.conf import settings
from aircox.models import Sound from aircox.models import Sound
from aircox.controllers import sound_monitor from aircox.controllers import sound_monitor
from aircox.test import Interface, interface from aircox.test import Interface, interface
@ -25,8 +27,8 @@ def logger():
@pytest.fixture @pytest.fixture
def interfaces(logger): def interfaces():
return { items = {
"SoundFile": Interface.inject( "SoundFile": Interface.inject(
sound_monitor, sound_monitor,
"SoundFile", "SoundFile",
@ -41,8 +43,11 @@ def interfaces(logger):
"sleep": None, "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 @pytest.fixture
@ -65,6 +70,23 @@ def modified_task(interfaces):
return sound_monitor.ModifiedTask() 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: class TestTask:
def test___init__(self, task): def test___init__(self, task):
assert task.timestamp is not None assert task.timestamp is not None
@ -111,7 +133,7 @@ class TestModifiedTask:
dt_now = now + modified_task.timeout_delta - tz.timedelta(hours=10) dt_now = now + modified_task.timeout_delta - tz.timedelta(hours=10)
datetime = Interface.inject(sound_monitor, "datetime", {"now": dt_now}) datetime = Interface.inject(sound_monitor, "datetime", {"now": dt_now})
def sleep(n): def sleep(imeta, n):
datetime._imeta.funcs[ datetime._imeta.funcs[
"now" "now"
] = modified_task.timestamp + tz.timedelta(hours=10) ] = modified_task.timestamp + tz.timedelta(hours=10)
@ -129,31 +151,124 @@ class TestModifiedTask:
class TestMonitorHandler: class TestMonitorHandler:
def test_monitor___init__(self): def test_on_created(self, monitor_handler, event):
pass 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): def test_on_deleted(self, monitor_handler, event):
pass 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): def test_on_moved(self, monitor_handler, event):
pass 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): def test_on_modified(self, monitor_handler, event):
pass 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): def test__submit(self, monitor_handler, event):
pass 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): key = f"prefix:{event.src_path}"
pass 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: class SoundMonitor:
def test_report(self): def test_report(self, monitor, program, logger):
pass monitor.report(program, "component", "content", logger=logger)
msg = f"{program}, component: content"
assert logger._trace("info", args=True) == (msg,)
def test_scan(self): def test_scan(self, monitor, program, logger):
pass interface = Interface(None, {"scan_for_program": None})
monitor.scan_for_program = interface.scan_for_program
dirs = monitor.scan(logger)
def test_monitor(self): assert logger._traces("info") == (
pass "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