Skip to content

Commit 8f2dd1e

Browse files
committed
OP#10282: Parse track files off the event loop
1 parent 1fe3737 commit 8f2dd1e

7 files changed

Lines changed: 365 additions & 44 deletions

File tree

‎OTAnalytics/adapter_ui/dummy_viewmodel.py‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -686,9 +686,9 @@ async def load_otconfig(self) -> None:
686686
)
687687
if not otconfig_file:
688688
return
689-
self._load_otconfig(otconfig_file)
689+
await self._load_otconfig(otconfig_file)
690690

691-
def _load_otconfig(self, otconfig_file: Path) -> None:
691+
async def _load_otconfig(self, otconfig_file: Path) -> None:
692692
proceed = self._ui_factory.info_box(
693693
message=(
694694
"This will load a stored configuration from file. \n"
@@ -701,7 +701,7 @@ def _load_otconfig(self, otconfig_file: Path) -> None:
701701
if proceed.canceled:
702702
return
703703
logger().info(f"{OTCONFIG_FILE_TYPE} file to load: {otconfig_file}")
704-
self._application.load_otconfig(file=Path(otconfig_file))
704+
await self._application.load_otconfig_async(file=Path(otconfig_file))
705705
self.show_current_project()
706706
self.update_svz_metadata_view()
707707

@@ -813,7 +813,7 @@ async def load_tracks(self) -> None:
813813
if not track_files:
814814
return
815815
logger().info(f"Tracks files to load: {track_files}")
816-
self._application.add_tracks_of_files(track_files=track_files)
816+
await self._application.add_tracks_of_files_async(track_files=track_files)
817817

818818
async def load_configuration(self) -> None: # sourcery skip: avoid-builtin-shadow
819819
# INFO: Current behavior: Overwrites existing sections
@@ -832,7 +832,7 @@ async def load_configuration(self) -> None: # sourcery skip: avoid-builtin-shad
832832
elif configuration_file.suffix == f".{OTFLOW_FILE_TYPE}":
833833
self._load_otflow(configuration_file)
834834
elif configuration_file.suffix == f".{OTCONFIG_FILE_TYPE}":
835-
self._load_otconfig(configuration_file)
835+
await self._load_otconfig(configuration_file)
836836
else:
837837
raise ValueError("Configuration file to load has unknown file extension")
838838

‎OTAnalytics/application/application.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,17 +309,36 @@ def save_otconfig(self, file: Path) -> None:
309309
self._save_otconfig(file)
310310

311311
def load_otconfig(self, file: Path) -> None:
312+
"""Load an otconfig, blocking. Outside an event loop only."""
312313
self._load_otconfig.load(file)
313314

315+
async def load_otconfig_async(self, file: Path) -> None:
316+
"""Load an otconfig without blocking the event loop.
317+
318+
Args:
319+
file (Path): the otconfig file.
320+
"""
321+
await self._load_otconfig.load_async(file)
322+
314323
def add_tracks_of_files(self, track_files: list[Path]) -> None:
315324
"""
316325
Load a multiple track files.
317326
327+
Outside an event loop only. Inside one use `add_tracks_of_files_async`.
328+
318329
Args:
319330
track_files (list[Path]): files in ottrk format
320331
"""
321332
self._load_track_files(track_files)
322333

334+
async def add_tracks_of_files_async(self, track_files: list[Path]) -> None:
335+
"""Load multiple track files without blocking the event loop.
336+
337+
Args:
338+
track_files (list[Path]): files in ottrk format
339+
"""
340+
await self._load_track_files.load(track_files)
341+
323342
def delete_all_tracks(self) -> None:
324343
"""Delete all tracks."""
325344
self._datastore.delete_all_tracks()

‎OTAnalytics/application/use_cases/load_otconfig.py‎

Lines changed: 60 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from pathlib import Path
2+
from typing import Awaitable, Callable
23

3-
from OTAnalytics.application.parser.config_parser import ConfigParser
4+
from OTAnalytics.application.parser.config_parser import ConfigParser, OtConfig
45
from OTAnalytics.application.parser.deserializer import Deserializer
56
from OTAnalytics.application.state import ConfigurationFile
67
from OTAnalytics.application.use_cases.add_new_remark import AddNewRemark
@@ -44,29 +45,66 @@ def __init__(
4445
self._subject = Subject[ConfigurationFile]()
4546

4647
def load(self, file: Path) -> None:
47-
self._reset_application.reset()
48-
config = self._config_parser.parse(file)
48+
"""Load an otconfig and its track files, blocking.
49+
50+
For use outside an event loop only, such as preloading a config file given
51+
on the command line at startup. Inside an event loop use `load_async`.
52+
53+
Args:
54+
file (Path): the otconfig file.
55+
"""
56+
self._apply(file, self._load_track_files)
57+
58+
async def load_async(self, file: Path) -> None:
59+
"""Load an otconfig and its track files, keeping the ui responsive.
60+
61+
Args:
62+
file (Path): the otconfig file.
63+
"""
64+
await self._apply_async(file, self._load_track_files.load)
65+
66+
def _apply(self, file: Path, load_track_files: Callable[[list], None]) -> None:
67+
config = self._begin(file)
68+
try:
69+
self._publish_before_tracks(config)
70+
load_track_files(list(config.analysis.track_files))
71+
self._publish_after_tracks(file, config)
72+
except (SectionAlreadyExists, FlowAlreadyExists) as cause:
73+
self._abort(cause)
74+
75+
async def _apply_async(
76+
self, file: Path, load_track_files: Callable[[list], Awaitable[None]]
77+
) -> None:
78+
config = self._begin(file)
4979
try:
50-
self._update_project(
51-
config.project.name, config.project.start_date, config.project.metadata
52-
)
53-
self._add_videos.add(config.videos)
54-
self._add_sections.add(config.sections)
55-
self._add_flows.add(config.flows)
56-
self._load_track_files(list(config.analysis.track_files))
57-
if config.remark:
58-
self._add_new_remark.add(config.remark)
59-
self._subject.notify(
60-
ConfigurationFile(
61-
file,
62-
self._deserialize(file),
63-
)
64-
)
80+
self._publish_before_tracks(config)
81+
await load_track_files(list(config.analysis.track_files))
82+
self._publish_after_tracks(file, config)
6583
except (SectionAlreadyExists, FlowAlreadyExists) as cause:
66-
self._reset_application.reset()
67-
raise UnableToLoadOtconfigFile(
68-
"Error while loading otconfig file. Abort loading!"
69-
) from cause
84+
self._abort(cause)
85+
86+
def _begin(self, file: Path) -> OtConfig:
87+
self._reset_application.reset()
88+
return self._config_parser.parse(file)
89+
90+
def _publish_before_tracks(self, config: OtConfig) -> None:
91+
self._update_project(
92+
config.project.name, config.project.start_date, config.project.metadata
93+
)
94+
self._add_videos.add(config.videos)
95+
self._add_sections.add(config.sections)
96+
self._add_flows.add(config.flows)
97+
98+
def _publish_after_tracks(self, file: Path, config: OtConfig) -> None:
99+
if config.remark:
100+
self._add_new_remark.add(config.remark)
101+
self._subject.notify(ConfigurationFile(file, self._deserialize(file)))
102+
103+
def _abort(self, cause: Exception) -> None:
104+
self._reset_application.reset()
105+
raise UnableToLoadOtconfigFile(
106+
"Error while loading otconfig file. Abort loading!"
107+
) from cause
70108

71109
def register(self, observer: OBSERVER[ConfigurationFile]) -> None:
72110
self._subject.register(observer)

‎OTAnalytics/application/use_cases/load_track_files.py‎

Lines changed: 80 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import asyncio
12
from pathlib import Path
23

34
from OTAnalytics.application.datastore import VideoParser
45
from OTAnalytics.application.logger import logger
5-
from OTAnalytics.application.parser.track_parser import TrackParser
6+
from OTAnalytics.application.parser.track_parser import TrackParser, TracksParseResult
67
from OTAnalytics.application.state import TracksMetadata, VideosMetadata
78
from OTAnalytics.domain.progress import ProgressbarBuilder
89
from OTAnalytics.domain.track_repository import TrackFileRepository, TrackRepository
@@ -31,28 +32,95 @@ def __init__(
3132
self._videos_metadata = videos_metadata
3233

3334
def __call__(self, files: list[Path]) -> None:
35+
"""Load and parse track files together with their videos, blocking.
36+
37+
For use outside an event loop only, such as preloading files given on the
38+
command line at startup. Nothing is connected then, so there is nothing to
39+
yield to. Inside an event loop use `load` instead, which keeps the ui
40+
responsive.
41+
42+
Args:
43+
files (list[Path]): files in ottrk format.
44+
45+
Raises:
46+
RuntimeError: if called while an event loop is running.
3447
"""
35-
Load and parse the given track file together with the corresponding video file.
48+
if self._event_loop_is_running():
49+
raise RuntimeError(
50+
"Parsing track files blocks the event loop and freezes the ui."
51+
" Use load() instead."
52+
)
53+
if files_to_load := self._files_to_load(files):
54+
self._publish(self._parse(files_to_load), files_to_load)
3655

37-
Each video is resolved relative to the parent folder of the track file it was
38-
parsed from, so track files from different folders each find their own video.
39-
This relies on `TrackParser.parse_files` returning exactly one `VideoMetadata`
40-
per input file, in input order; `strict=True` turns a violation of that into
41-
an error rather than silently dropping videos.
56+
async def load(self, files: list[Path]) -> None:
57+
"""Load and parse track files together with their videos.
58+
59+
Parsing runs on a worker thread, so the ui keeps repainting while it runs.
60+
Everything that touches a repository stays on the event loop.
61+
62+
Args:
63+
files (list[Path]): files in ottrk format.
64+
"""
65+
if files_to_load := self._files_to_load(files):
66+
# parse_files must stay pure: no repository, no observer, no ui. It runs
67+
# on a worker thread here, and repositories notify observers that mutate
68+
# widgets, which is only safe on the event loop.
69+
parse_result = await asyncio.to_thread(self._parse, files_to_load)
70+
self._publish(parse_result, files_to_load)
71+
72+
@staticmethod
73+
def _event_loop_is_running() -> bool:
74+
try:
75+
asyncio.get_running_loop()
76+
except RuntimeError:
77+
return False
78+
return True
79+
80+
def _files_to_load(self, files: list[Path]) -> list[Path]:
81+
"""Select the files that are not loaded already.
4282
4383
Args:
44-
files (Path): files in ottrk format.
84+
files (list[Path]): files the caller asked to load.
85+
86+
Returns:
87+
list[Path]: the files still to be parsed.
4588
"""
4689
if not files:
47-
return
90+
return []
4891
files_to_load = [
4992
file for file in files if not self._is_file_already_loaded(file)
5093
]
5194
self._log_already_loaded_files(files, files_to_load)
52-
if not files_to_load:
53-
return
95+
return files_to_load
96+
97+
def _parse(self, files_to_load: list[Path]) -> TracksParseResult:
98+
"""Parse the given track files. Pure: safe to run off the event loop.
99+
100+
Args:
101+
files_to_load (list[Path]): files in ottrk format.
102+
103+
Returns:
104+
TracksParseResult: the parsed tracks and their metadata.
105+
"""
54106
logger().info(f"Loading {len(files_to_load)} track files and videos...")
55-
parse_result = self._track_parser.parse_files(files_to_load)
107+
return self._track_parser.parse_files(files_to_load)
108+
109+
def _publish(
110+
self, parse_result: TracksParseResult, files_to_load: list[Path]
111+
) -> None:
112+
"""Publish the parse result to the repositories. Event loop only.
113+
114+
Each video is resolved relative to the parent folder of the track file it was
115+
parsed from, so track files from different folders each find their own video.
116+
This relies on `TrackParser.parse_files` returning exactly one `VideoMetadata`
117+
per input file, in input order; `strict=True` turns a violation of that into
118+
an error rather than silently dropping videos.
119+
120+
Args:
121+
parse_result (TracksParseResult): what the parser produced.
122+
files_to_load (list[Path]): the files handed to the parser.
123+
"""
56124
for video_metadata in parse_result.videos_metadata:
57125
self._videos_metadata.update(video_metadata)
58126

‎tests/unit/OTAnalytics/adapter_ui/test_dummy_viewmodel_loading.py‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,11 @@ def create_given(
3333
provide_track_files.provide = AsyncMock(return_value=tracks or [])
3434
provide_video_files = Mock(spec=ProvideVideoFiles)
3535
provide_video_files.provide = AsyncMock(return_value=videos or [])
36+
application = Mock()
37+
application.add_tracks_of_files_async = AsyncMock()
38+
application.load_otconfig_async = AsyncMock()
3639
return Given(
37-
application=Mock(),
40+
application=application,
3841
provide_track_files=provide_track_files,
3942
provide_video_files=provide_video_files,
4043
)
@@ -70,15 +73,20 @@ def _build(given: Given) -> DummyViewModel:
7073

7174
class TestLoadTracks:
7275
async def test_forwards_the_provided_files(self) -> None:
73-
"""#Requirement https://openproject.platomo.de/wp/10280"""
76+
"""Loading is awaited so the ui keeps repainting while parsing runs.
77+
78+
#Requirement https://openproject.platomo.de/wp/10280
79+
#Requirement https://openproject.platomo.de/wp/10282
80+
"""
7481
given = create_given(tracks=TRACK_FILES)
7582
target = create_target(given)
7683

7784
await target.load_tracks()
7885

79-
given.application.add_tracks_of_files.assert_called_once_with(
86+
given.application.add_tracks_of_files_async.assert_awaited_once_with(
8087
track_files=TRACK_FILES
8188
)
89+
given.application.add_tracks_of_files.assert_not_called()
8290

8391
async def test_loads_nothing_when_the_provider_returns_nothing(self) -> None:
8492
"""#Requirement https://openproject.platomo.de/wp/10280"""
@@ -87,7 +95,7 @@ async def test_loads_nothing_when_the_provider_returns_nothing(self) -> None:
8795

8896
await target.load_tracks()
8997

90-
given.application.add_tracks_of_files.assert_not_called()
98+
given.application.add_tracks_of_files_async.assert_not_awaited()
9199

92100

93101
class TestAddVideo:

0 commit comments

Comments
 (0)