Skip to content

Commit 79e0cd0

Browse files
authored
Merge pull request #846 from OpenTrafficCam/task/10323-save-and-reopen-a-project-in-s3-mode
Task/10323 save and reopen a project in s3 mode
2 parents 52934cb + a72f0a4 commit 79e0cd0

22 files changed

Lines changed: 599 additions & 43 deletions

File tree

‎OTAnalytics/adapter_ui/dummy_viewmodel.py‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,12 @@ def show_current_project(self, _: Any = None) -> None:
627627

628628
async def save_otconfig(self) -> None:
629629
save_suggestion = self._application.suggest_save_path(OTCONFIG_FILE_TYPE)
630+
if self._application.get_current_key_prefix() is not None:
631+
# A project that names a location was opened by an operator naming
632+
# it, not chosen from a picker (OP#10323 decision 1), so it always
633+
# re-saves to its own name and place with no new UI.
634+
await self._save_otconfig(save_suggestion.file_path)
635+
return
630636
configuration_file = await self._ui_factory.ask_for_save_file_path(
631637
title="Save configuration as",
632638
filetypes=[(f"{OTCONFIG_FILE_TYPE} file", f"*.{OTCONFIG_FILE_TYPE}")],
@@ -636,9 +642,9 @@ async def save_otconfig(self) -> None:
636642
)
637643
if not configuration_file:
638644
return
639-
self._save_otconfig(configuration_file)
645+
await self._save_otconfig(configuration_file)
640646

641-
def _save_otconfig(self, otconfig_file: Path) -> None:
647+
async def _save_otconfig(self, otconfig_file: Path) -> None:
642648
# Ensure the file has the correct extension
643649
otconfig_file_str = ensure_file_extension_is_present(
644650
str(otconfig_file), [f"*.{OTCONFIG_FILE_TYPE}"], f".{OTCONFIG_FILE_TYPE}"
@@ -647,7 +653,7 @@ def _save_otconfig(self, otconfig_file: Path) -> None:
647653

648654
logger().info(f"Config file to save: {otconfig_file}")
649655
try:
650-
self._application.save_otconfig(otconfig_file)
656+
await self._application.save_otconfig(otconfig_file)
651657
except NoSectionsToSave:
652658
message = (
653659
f"{MESSAGE_CONFIGURATION_NOT_SAVED}"
@@ -943,6 +949,12 @@ def _load_otflow(self, otflow_file: Path) -> None:
943949

944950
async def save_configuration(self) -> None:
945951
save_suggestion = self._application.suggest_save_path(OTCONFIG_FILE_TYPE)
952+
if self._application.get_current_key_prefix() is not None:
953+
# A project that names a location is always an otconfig and always
954+
# re-saves to its own name and place with no new UI (OP#10323
955+
# decision 1 & 5).
956+
await self._save_otconfig(save_suggestion.file_path)
957+
return
946958
configuration_file = await self._ui_factory.ask_for_save_file_path(
947959
title="Save configuration as",
948960
filetypes=[
@@ -967,13 +979,13 @@ async def save_configuration(self) -> None:
967979
if configuration_file.suffix == f".{OTFLOW_FILE_TYPE}":
968980
self._save_otflow(configuration_file)
969981
elif configuration_file.suffix == f".{OTCONFIG_FILE_TYPE}":
970-
self._save_otconfig(configuration_file)
982+
await self._save_otconfig(configuration_file)
971983
else:
972984
raise ValueError("Configuration file to save has unknown file extension")
973985

974986
async def quick_save_configuration(self) -> None:
975987
try:
976-
self._application.quick_save_configuration()
988+
await self._application.quick_save_configuration()
977989
except NoExistingFileToSave:
978990
await self.save_configuration()
979991

‎OTAnalytics/application/application.py‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
ExportFormat,
99
)
1010
from OTAnalytics.application.datastore import Datastore
11+
from OTAnalytics.application.key_prefix import S3KeyPrefix
1112
from OTAnalytics.application.project import SvzMetadata
1213
from OTAnalytics.application.state import (
1314
ActionState,
15+
CurrentKeyPrefix,
1416
FileState,
1517
FlowState,
1618
SectionState,
@@ -157,6 +159,7 @@ def __init__(
157159
export_track_statistics: ExportTrackStatistics,
158160
get_current_remark: GetCurrentRemark,
159161
update_count_plots: CountPlotsUpdater,
162+
current_key_prefix: CurrentKeyPrefix,
160163
) -> None:
161164
self._datastore: Datastore = datastore
162165
self.track_state: TrackState = track_state
@@ -204,6 +207,7 @@ def __init__(
204207
self._export_track_statistics = export_track_statistics
205208
self._get_current_remark = get_current_remark
206209
self._update_count_plots = update_count_plots
210+
self._current_key_prefix = current_key_prefix
207211

208212
def connect_observers(self) -> None:
209213
"""
@@ -305,8 +309,16 @@ def remove_flow(self, flow_id: FlowId) -> None:
305309
def update_flow(self, flow: Flow) -> None:
306310
self._datastore.update_flow(flow)
307311

308-
def save_otconfig(self, file: Path) -> None:
309-
self._save_otconfig(file)
312+
async def save_otconfig(self, file: Path) -> None:
313+
await self._save_otconfig(file)
314+
315+
def get_current_key_prefix(self) -> S3KeyPrefix | None:
316+
"""Where the current project's data lives, or None outside s3 mode.
317+
318+
A project naming a prefix is how the save path knows to skip the
319+
file-picker dialog: see OP#10323.
320+
"""
321+
return self._current_key_prefix.get()
310322

311323
def load_otconfig(self, file: Path) -> None:
312324
"""Load an otconfig, blocking. Outside an event loop only."""
@@ -675,8 +687,8 @@ def update_svz_metadata(self, metadata: SvzMetadata) -> None:
675687
def get_track_repository_size(self) -> int:
676688
return self._track_repository_size.get()
677689

678-
def quick_save_configuration(self) -> None:
679-
self._quick_save_configuration.save()
690+
async def quick_save_configuration(self) -> None:
691+
await self._quick_save_configuration.save()
680692

681693
def config_has_changed(self) -> bool:
682694
return self._config_has_changed.has_changed()
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from abc import ABC, abstractmethod
2+
from pathlib import Path
3+
4+
from OTAnalytics.application.key_prefix import S3KeyPrefix
5+
6+
7+
class UploadOtconfig(ABC):
8+
"""Puts a saved otconfig where a later s3-mode load can find it again.
9+
10+
Nothing here asks what the transfer mode is: the wiring picks an
11+
implementation, and the implementation is the answer. See ADR 0004 and
12+
OP#10323's decision log.
13+
"""
14+
15+
@abstractmethod
16+
async def upload(self, file: Path, key_prefix: S3KeyPrefix) -> None:
17+
raise NotImplementedError
18+
19+
20+
class NoOtconfigUpload(UploadOtconfig):
21+
"""Wired whenever s3 is not configured; saving stays purely local."""
22+
23+
async def upload(self, file: Path, key_prefix: S3KeyPrefix) -> None:
24+
return None

‎OTAnalytics/application/use_cases/config.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from pathlib import Path
22

33
from OTAnalytics.application.datastore import Datastore
4+
from OTAnalytics.application.logger import logger
45
from OTAnalytics.application.parser.config_parser import ConfigParser
56
from OTAnalytics.application.state import ConfigurationFile, CurrentKeyPrefix, FileState
7+
from OTAnalytics.application.upload_otconfig import UploadOtconfig
68
from OTAnalytics.application.use_cases.get_current_remark import GetCurrentRemark
79

810

@@ -22,14 +24,16 @@ def __init__(
2224
state: FileState,
2325
get_current_remark: GetCurrentRemark,
2426
current_key_prefix: CurrentKeyPrefix,
27+
otconfig_upload: UploadOtconfig,
2528
) -> None:
2629
self._datastore = datastore
2730
self._config_parser = config_parser
2831
self._state = state
2932
self._get_current_remark = get_current_remark
3033
self._current_key_prefix = current_key_prefix
34+
self._otconfig_upload = otconfig_upload
3135

32-
def __call__(self, file: Path) -> None:
36+
async def __call__(self, file: Path) -> None:
3337
project = self._datastore.project
3438

3539
# Collect all validation errors
@@ -50,6 +54,7 @@ def __call__(self, file: Path) -> None:
5054
# The same value reaches `convert` below. Passing it to only one of the
5155
# two would make every project report itself as permanently unsaved.
5256
s3_key_prefix = self._current_key_prefix.get()
57+
logger().info(f"Saving otconfig to '{file}'")
5358
self._config_parser.serialize(
5459
project=project,
5560
video_files=video_files,
@@ -75,3 +80,6 @@ def __call__(self, file: Path) -> None:
7580
),
7681
)
7782
)
83+
if s3_key_prefix is not None:
84+
logger().info(f"Uploading otconfig '{file}' to s3 under '{s3_key_prefix}'")
85+
await self._otconfig_upload.upload(file, s3_key_prefix)

‎OTAnalytics/application/use_cases/quick_save_configuration.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@ def __init__(
1414
self._save_otflow = save_otflow
1515
self._save_otconfig = save_otconfig
1616

17-
def save(self) -> None:
17+
async def save(self) -> None:
1818
if not (config := self._state.last_saved_config.get()):
1919
raise NoExistingFileToSave("No saved file to save configuration to")
2020

2121
if config.is_otflow:
2222
self._save_otflow.save(config.file)
2323
elif config.is_otconfig:
24-
self._save_otconfig(config.file)
24+
await self._save_otconfig(config.file)
2525
else:
2626
raise UnsupportedConfiguration(
2727
"Unable to save configuration. "

‎OTAnalytics/plugin_parser/otconfig_parser.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,13 @@ def convert(
384384
) -> dict:
385385
parent_folder = file.parent
386386
project_content = project.to_dict()
387+
if s3_key_prefix is not None:
388+
# Data is picked per session by time range (OP#10283), so a
389+
# project's own file references have no reader in s3 mode.
390+
# Recording them would only leave stale paths into the ephemeral
391+
# `user_source` staging area behind. See OP#10323's decision log.
392+
video_files = []
393+
track_files = []
387394
video_content = self._video_parser.convert(
388395
video_files,
389396
relative_to=parent_folder,
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from pathlib import Path
2+
3+
from OTAnalytics.application.key_prefix import S3KeyPrefix
4+
from OTAnalytics.application.upload_otconfig import UploadOtconfig
5+
from OTAnalytics.plugin_s3.upload import S3Upload
6+
7+
8+
class S3OtconfigUpload(UploadOtconfig):
9+
"""Uploads a saved otconfig beside the project's other objects in s3."""
10+
11+
def __init__(self, upload: S3Upload) -> None:
12+
self._upload = upload
13+
14+
async def upload(self, file: Path, key_prefix: S3KeyPrefix) -> None:
15+
await self._upload.upload(src=file, key=f"{key_prefix}/{file.name}")

‎OTAnalytics/plugin_ui/base_application.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
SwitchToNext,
5959
SwitchToPrevious,
6060
)
61+
from OTAnalytics.application.upload_otconfig import NoOtconfigUpload, UploadOtconfig
6162
from OTAnalytics.application.use_cases.add_new_remark import AddNewRemark
6263
from OTAnalytics.application.use_cases.apply_cli_cuts import ApplyCliCuts
6364
from OTAnalytics.application.use_cases.assignment_repository import (
@@ -436,8 +437,18 @@ def save_otconfig(self) -> SaveOtconfig:
436437
self.file_state,
437438
self.get_current_remark,
438439
self.current_key_prefix,
440+
self.otconfig_upload,
439441
)
440442

443+
@cached_property
444+
def otconfig_upload(self) -> UploadOtconfig:
445+
"""Puts a saved otconfig where s3 mode can find it again.
446+
447+
Overridden where s3 can be configured. This starter never sees a
448+
project naming a location, so saving never needs to upload anywhere.
449+
"""
450+
return NoOtconfigUpload()
451+
441452
@cached_property
442453
def current_key_prefix(self) -> CurrentKeyPrefix:
443454
"""One holder for the whole application.

‎OTAnalytics/plugin_ui/gui_application.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ def application(self) -> OTAnalyticsApplication:
209209
self.export_track_statistics,
210210
self.get_current_remark,
211211
self.update_count_plots,
212+
self.current_key_prefix,
212213
)
213214

214215
@cached_property

‎OTAnalytics/plugin_ui/nicegui_application.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
RefuseAnyProjectLocation,
1414
ValidateProjectLocation,
1515
)
16+
from OTAnalytics.application.upload_otconfig import NoOtconfigUpload, UploadOtconfig
1617
from OTAnalytics.application.use_cases.ask_for_load_window import AskForLoadWindow
1718
from OTAnalytics.application.use_cases.provide_input_files import (
1819
ProvideTrackFiles,
@@ -32,12 +33,14 @@
3233
from OTAnalytics.plugin_s3.download import S3Download
3334
from OTAnalytics.plugin_s3.download_objects import DownloadObjects
3435
from OTAnalytics.plugin_s3.list_objects import S3ListObjects
36+
from OTAnalytics.plugin_s3.otconfig_upload import S3OtconfigUpload
3537
from OTAnalytics.plugin_s3.project_location import RequireWellFormedKeyPrefix
3638
from OTAnalytics.plugin_s3.s3_file_providers import (
3739
S3TrackFileProvider,
3840
S3VideoFileProvider,
3941
)
4042
from OTAnalytics.plugin_s3.store import S3Store
43+
from OTAnalytics.plugin_s3.upload import S3Upload
4144
from OTAnalytics.plugin_ui.gui_application import OtAnalyticsGuiApplicationStarter
4245
from OTAnalytics.plugin_ui.nicegui_gui.nicegui.progressbar import (
4346
NiceguiProgressbarBuilder,
@@ -328,6 +331,12 @@ def validate_project_location(self) -> ValidateProjectLocation:
328331
return RequireWellFormedKeyPrefix()
329332
return RefuseAnyProjectLocation()
330333

334+
@cached_property
335+
def otconfig_upload(self) -> UploadOtconfig:
336+
if self.s3_config:
337+
return S3OtconfigUpload(S3Upload(self.s3_store))
338+
return NoOtconfigUpload()
339+
331340
@cached_property
332341
def reset_application(self) -> ResetApplication:
333342
"""Resetting a project also frees the staged downloads it used.

0 commit comments

Comments
 (0)