Skip to content

Commit aa1dabc

Browse files
authored
Merge pull request #821 from OpenTrafficCam/bug/9548-incomplete-filenames-in-otanalytics-if-video-name-has-multiple-dots
bug/9548-incomplete-filenames-in-otanalytics-if-video-name-has-multiple-dots
2 parents 33eb122 + 2dd6062 commit aa1dabc

61 files changed

Lines changed: 2003 additions & 532 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/system-test.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
name: System Test With Pytest
3+
4+
on:
5+
# Run tests each time a PR is opened or changed.
6+
# Allow other Workflows (e.g., build workflows) to call this workflow.
7+
pull_request:
8+
workflow_call:
9+
10+
permissions: read-all
11+
12+
jobs:
13+
test:
14+
name: Execute system tests across various operating systems and Python versions.
15+
strategy:
16+
matrix:
17+
os: [ubuntu-latest, windows-latest]
18+
py: ["3.12"]
19+
fail-fast: true
20+
runs-on: ${{ matrix.os }}
21+
steps:
22+
- name: Checkout repository
23+
uses: actions/checkout@v6
24+
- name: Set up Python ${{ matrix.py }}
25+
uses: actions/setup-python@v6
26+
with:
27+
python-version: ${{ matrix.py }}
28+
- name: Run Python System Tests
29+
uses: platomo/test-python-app-action@v4
30+
timeout-minutes: 60
31+
with:
32+
py-version: ${{ matrix.py }}
33+
package-path: OTAnalytics
34+
test-path: tests/system

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
uses: actions/setup-python@v6
2626
with:
2727
python-version: ${{ matrix.py }}
28-
- name: Run Python Tests
28+
- name: Run Python Unit Tests
2929
uses: platomo/test-python-app-action@v4
3030
timeout-minutes: 60
3131
with:

OTAnalytics/adapter_ui/dummy_viewmodel.py

Lines changed: 46 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
RELATIVE_SECTION_OFFSET,
3434
SUPPORTED_FORMATS,
3535
)
36+
from OTAnalytics.adapter_ui.file_export_dto import ExportFileDto
3637
from OTAnalytics.adapter_ui.flow_adapter import (
3738
GeometricCenterCalculator,
3839
InnerSegmentsCenterCalculator,
@@ -61,6 +62,7 @@
6162
)
6263
from OTAnalytics.application.config import (
6364
CONTEXT_FILE_TYPE_EVENTS,
65+
CONTEXT_FILE_TYPE_ROAD_USER_ASSIGNMENTS,
6466
CONTEXT_FILE_TYPE_TRACK_STATISTICS,
6567
CUTTING_SECTION_MARKER,
6668
OTCONFIG_FILE_TYPE,
@@ -97,6 +99,7 @@
9799
UpdateSectionCoordinates,
98100
)
99101
from OTAnalytics.application.use_cases.export_events import (
102+
EventExportSpecification,
100103
EventListExporter,
101104
ExporterNotFoundError,
102105
)
@@ -109,6 +112,7 @@
109112
ExportSpecification,
110113
)
111114
from OTAnalytics.application.use_cases.save_otflow import NoSectionsToSave
115+
from OTAnalytics.application.use_cases.suggest_save_path import SavePathSuggestion
112116
from OTAnalytics.application.use_cases.track_statistics_export import (
113117
TrackStatisticsExportSpecification,
114118
)
@@ -611,13 +615,13 @@ def show_current_project(self, _: Any = None) -> None:
611615
self.frame_project.update(name=project.name, start_date=project.start_date)
612616

613617
async def save_otconfig(self) -> None:
614-
suggested_save_path = self._application.suggest_save_path(OTCONFIG_FILE_TYPE)
618+
save_suggestion = self._application.suggest_save_path(OTCONFIG_FILE_TYPE)
615619
configuration_file = await self._ui_factory.ask_for_save_file_path(
616620
title="Save configuration as",
617621
filetypes=[(f"{OTCONFIG_FILE_TYPE} file", f"*.{OTCONFIG_FILE_TYPE}")],
618622
defaultextension=f".{OTCONFIG_FILE_TYPE}",
619-
initialfile=suggested_save_path.name,
620-
initialdir=suggested_save_path.parent,
623+
initialfile=save_suggestion.file_path.name,
624+
initialdir=save_suggestion.save_directory,
621625
)
622626
if not configuration_file:
623627
return
@@ -870,16 +874,16 @@ def _load_otflow(self, otflow_file: Path) -> None:
870874
self.refresh_items_on_canvas()
871875

872876
async def save_configuration(self) -> None:
873-
suggested_save_path = self._application.suggest_save_path(OTCONFIG_FILE_TYPE)
877+
save_suggestion = self._application.suggest_save_path(OTCONFIG_FILE_TYPE)
874878
configuration_file = await self._ui_factory.ask_for_save_file_path(
875879
title="Save configuration as",
876880
filetypes=[
877881
(f"{OTCONFIG_FILE_TYPE} file", f"*.{OTCONFIG_FILE_TYPE}"),
878882
(f"{OTFLOW_FILE_TYPE} file", f"*.{OTFLOW_FILE_TYPE}"),
879883
],
880884
defaultextension=f".{OTCONFIG_FILE_TYPE}",
881-
initialfile=suggested_save_path.name,
882-
initialdir=suggested_save_path.parent,
885+
initialfile=save_suggestion.file_path.name,
886+
initialdir=save_suggestion.save_directory,
883887
)
884888
if not configuration_file.stem:
885889
return
@@ -1389,33 +1393,40 @@ async def export_events(self) -> None:
13891393
for key, exporter in self._event_list_export_formats.items()
13901394
}
13911395
try:
1392-
event_list_exporter, file = await self._configure_event_exporter(
1396+
event_list_exporter, export_config = await self._configure_event_exporter(
13931397
export_format_extensions
13941398
)
1395-
self._application.export_events(Path(file), event_list_exporter)
1399+
specification = EventExportSpecification(
1400+
export_directory=export_config.export_directory,
1401+
export_filename_stem=export_config.file_stem,
1402+
export_mode=OVERWRITE,
1403+
)
1404+
self._application.export_events(specification, event_list_exporter)
13961405
logger().info(
1397-
f"Exporting eventlist using {event_list_exporter.get_name()} to {file}"
1406+
f"Exporting eventlist using {event_list_exporter.get_name()} to "
1407+
f"{export_config.as_file_path()}"
13981408
)
13991409
except CancelExportFile:
14001410
logger().info("User canceled configuration of export")
14011411

14021412
async def _configure_event_exporter(
14031413
self, export_format_extensions: dict[str, str]
1404-
) -> tuple[EventListExporter, Path]:
1414+
) -> tuple[EventListExporter, ExportFileDto]:
14051415
export_config = await self._ui_factory.configure_export_file(
14061416
title="Export events",
14071417
export_format_extensions=export_format_extensions,
1408-
initial_file_stem=CONTEXT_FILE_TYPE_EVENTS,
1418+
context_file_type=CONTEXT_FILE_TYPE_EVENTS,
14091419
viewmodel=self,
14101420
)
1411-
file = export_config.file
1412-
export_format = export_config.export_format
1413-
event_list_exporter = self._event_list_export_formats.get(export_format, None)
1421+
event_list_exporter = self._event_list_export_formats.get(
1422+
export_config.export_format, None
1423+
)
14141424
if event_list_exporter is None:
14151425
raise ExporterNotFoundError(
14161426
f"{event_list_exporter} is not a valid export format"
14171427
)
1418-
return event_list_exporter, file
1428+
1429+
return event_list_exporter, export_config
14191430

14201431
def set_track_offset(self, offset_x: float, offset_y: float) -> None:
14211432
start_msg_popup = self._ui_factory.minimal_info_box(
@@ -1697,18 +1708,20 @@ async def export_road_user_assignments(self) -> None:
16971708
export_config = await self._ui_factory.configure_export_file(
16981709
title="Export road user assignments",
16991710
export_format_extensions=export_formats,
1700-
initial_file_stem="road_user_assignments",
1711+
context_file_type=CONTEXT_FILE_TYPE_ROAD_USER_ASSIGNMENTS,
17011712
viewmodel=self,
17021713
)
17031714
logger().debug(export_config)
1704-
save_path = export_config.file
1705-
export_format = export_config.export_format
1706-
17071715
export_specification = ExportSpecification(
1708-
save_path, export_format, OVERWRITE
1716+
export_directory=export_config.export_directory,
1717+
export_filename_stem=export_config.file_stem,
1718+
format=export_config.export_format,
1719+
export_mode=OVERWRITE,
17091720
)
17101721
self._application.export_road_user_assignments(export_specification)
1711-
logger().info(f"Exporting road user assignments to {save_path}")
1722+
logger().info(
1723+
f"Exporting road user assignments to {export_config.as_file_path()}"
1724+
)
17121725
except CancelExportFile:
17131726
logger().info("User canceled configuration of export")
17141727

@@ -1776,7 +1789,9 @@ def update_svz_metadata_view(self, _: Any = None) -> None:
17761789
def update_remark_view(self, _: Any = None) -> None:
17771790
self.frame_remark.load_remark()
17781791

1779-
def get_save_path_suggestion(self, file_type: str, context_file_type: str) -> Path:
1792+
def get_save_path_suggestion(
1793+
self, file_type: str, context_file_type: str
1794+
) -> SavePathSuggestion:
17801795
return self._application.suggest_save_path(file_type, context_file_type)
17811796

17821797
def set_frame_track_statistics(self, frame: AbstractFrameTrackStatistics) -> None:
@@ -1808,17 +1823,19 @@ async def export_track_statistics(self) -> None:
18081823
export_config = await self._ui_factory.configure_export_file(
18091824
title="Export track statistics",
18101825
export_format_extensions=export_formats,
1811-
initial_file_stem=CONTEXT_FILE_TYPE_TRACK_STATISTICS,
1826+
context_file_type=CONTEXT_FILE_TYPE_TRACK_STATISTICS,
18121827
viewmodel=self,
18131828
)
18141829
logger().debug(export_config)
1815-
save_path = export_config.file
1816-
export_format = export_config.export_format
1817-
18181830
export_specification = TrackStatisticsExportSpecification(
1819-
save_path, export_format, OVERWRITE
1831+
export_directory=export_config.export_directory,
1832+
export_filename_stem=export_config.file_stem,
1833+
format=export_config.export_format,
1834+
export_mode=OVERWRITE,
1835+
)
1836+
destination = self._application.export_track_statistics(
1837+
export_specification
18201838
)
1821-
self._application.export_track_statistics(export_specification)
1822-
logger().info(f"Exporting track statistics to {save_path}")
1839+
logger().info(f"Exporting track statistics to {destination}")
18231840
except CancelExportFile:
18241841
logger().info("User canceled configuration of export")

OTAnalytics/adapter_ui/file_export_dto.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,28 @@
44

55
@dataclass(frozen=True)
66
class ExportFileDto:
7-
file: Path
7+
"""
8+
Data transfer object for file export information.
9+
10+
Attributes:
11+
export_directory (Path): The directory where the file will be exported.
12+
file_stem (str): The stem of the file name (without extension).
13+
export_format (str): The format of the exported file. The file extension.
14+
"""
15+
16+
export_directory: Path
17+
file_stem: str
18+
export_format_extension: str
819
export_format: str
20+
21+
@classmethod
22+
def from_file_path(cls, file_path: Path, export_format: str) -> "ExportFileDto":
23+
return cls(
24+
export_directory=file_path.parent,
25+
file_stem=file_path.stem,
26+
export_format_extension=file_path.suffix,
27+
export_format=export_format,
28+
)
29+
30+
def as_file_path(self) -> Path:
31+
return self.export_directory / f"{self.file_stem}{self.export_format_extension}"

OTAnalytics/adapter_ui/ui_factory.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ async def configure_export_file(
6464
self,
6565
title: str,
6666
export_format_extensions: dict[str, str],
67-
initial_file_stem: str,
67+
context_file_type: str,
6868
viewmodel: ViewModel,
6969
) -> ExportFileDto:
7070
raise NotImplementedError

OTAnalytics/adapter_ui/view_model.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from OTAnalytics.application import geometry
2929
from OTAnalytics.application.use_cases.cut_tracks_with_sections import CutTracksDto
3030
from OTAnalytics.application.use_cases.editor.section_editor import MetadataProvider
31+
from OTAnalytics.application.use_cases.suggest_save_path import SavePathSuggestion
3132
from OTAnalytics.domain.date import DateRange
3233
from OTAnalytics.domain.event import EventRepositoryEvent
3334
from OTAnalytics.domain.filter import FilterElement
@@ -447,7 +448,9 @@ def set_svz_metadata_frame(self, frame: AbstractFrameSvzMetadata) -> None:
447448
raise NotImplementedError
448449

449450
@abstractmethod
450-
def get_save_path_suggestion(self, file_type: str, context_file_type: str) -> Path:
451+
def get_save_path_suggestion(
452+
self, file_type: str, context_file_type: str
453+
) -> SavePathSuggestion:
451454
raise NotImplementedError
452455

453456
@abstractmethod

OTAnalytics/application/analysis/traffic_counting.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -899,7 +899,7 @@ class Exporter(ABC):
899899
"""
900900

901901
@abstractmethod
902-
def export(self, counts: Count, export_mode: ExportMode) -> None:
902+
def export(self, counts: Count, export_mode: ExportMode) -> Path:
903903
"""
904904
Export the given counts.
905905
@@ -908,6 +908,9 @@ def export(self, counts: Count, export_mode: ExportMode) -> None:
908908
export_mode (ExportMode): export mode specifies whether result data
909909
should overwrite file, be appended (or flushed to file in case
910910
of stateful exporter).
911+
912+
Returns:
913+
Path: path to the exported file.
911914
"""
912915
raise NotImplementedError
913916

@@ -1124,7 +1127,7 @@ def __init__(
11241127
self._traffic_counting = traffic_counting
11251128
self._exporter_factory = exporter_factory
11261129

1127-
def export(self, specification: CountingSpecificationDto) -> None:
1130+
def export(self, specification: CountingSpecificationDto) -> Path:
11281131
"""
11291132
Export the traffic countings based on the currently available events and flows.
11301133
@@ -1138,7 +1141,7 @@ def export(self, specification: CountingSpecificationDto) -> None:
11381141
flows, specification, self._traffic_counting.provide_get_sections_by_id()
11391142
)
11401143
exporter = self._exporter_factory.create_exporter(export_specification)
1141-
exporter.export(counts, specification.export_mode)
1144+
return exporter.export(counts, specification.export_mode)
11421145

11431146
def get_supported_formats(self) -> Iterable[ExportFormat]:
11441147
"""

OTAnalytics/application/analysis/traffic_counting_specification.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from dataclasses import dataclass
33
from datetime import datetime
44
from enum import Enum
5+
from pathlib import Path
56
from typing import Iterable
67

78
from OTAnalytics.application.export_formats.export_mode import ExportMode
@@ -43,7 +44,8 @@ class CountingSpecificationDto:
4344
interval_in_minutes: int
4445
modes: list[str]
4546
output_format: str
46-
output_file: str
47+
export_directory: Path
48+
export_filename_stem: str
4749
export_mode: ExportMode
4850
count_all_events: bool = False
4951
counting_event: CountingEvent = CountingEvent.START
@@ -55,7 +57,8 @@ def with_end(self, end: datetime) -> "CountingSpecificationDto":
5557
interval_in_minutes=self.interval_in_minutes,
5658
modes=self.modes,
5759
output_format=self.output_format,
58-
output_file=self.output_file,
60+
export_directory=self.export_directory,
61+
export_filename_stem=self.export_filename_stem,
5962
export_mode=self.export_mode,
6063
count_all_events=self.count_all_events,
6164
counting_event=self.counting_event,
@@ -83,12 +86,16 @@ def format(self) -> str:
8386
return self.counting_specification.output_format
8487

8588
@property
86-
def output_file(self) -> str:
87-
return self.counting_specification.output_file
89+
def export_directory(self) -> Path:
90+
return self.counting_specification.export_directory
91+
92+
@property
93+
def export_filename_stem(self) -> str:
94+
return self.counting_specification.export_filename_stem
8895

8996

9097
class ExportCounts(ABC):
91-
def export(self, specification: CountingSpecificationDto) -> None:
98+
def export(self, specification: CountingSpecificationDto) -> Path:
9299
raise NotImplementedError
93100

94101
def get_supported_formats(self) -> Iterable[ExportFormat]:

0 commit comments

Comments
 (0)