Skip to content

Commit c7353ac

Browse files
committed
[cli] Add tests for CSV delimiters and enforce constraints for delimiter lengths
1 parent 834db38 commit c7353ac

File tree

6 files changed

+121
-32
lines changed

6 files changed

+121
-32
lines changed

scenedetect/_cli/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1079,7 +1079,6 @@ def list_scenes_command(
10791079
"quiet": ctx.config.get_value("list-scenes", "quiet", quiet) or ctx.quiet_mode,
10801080
"row_separator": ctx.config.get_value("list-scenes", "row-separator"),
10811081
}
1082-
# TODO(#423): Need to validate that col_separator is a 1-character string after decoding.
10831082
ctx.add_command(cli_commands.list_scenes, list_scenes_args)
10841083

10851084

scenedetect/_cli/commands.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,8 @@ def list_scenes(
9898
scene_list=scenes,
9999
include_cut_list=not skip_cuts,
100100
cut_list=cuts,
101-
col_separator=col_separator.encode("utf-8").decode("unicode_escape"),
102-
row_separator=row_separator.encode("utf-8").decode("unicode_escape"),
101+
col_separator=col_separator,
102+
row_separator=row_separator,
103103
)
104104
# Suppress output if requested.
105105
if quiet:

scenedetect/_cli/config.py

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ def from_config(config_value: str, default: "ValidatedValue") -> "ValidatedValue
6060
"""
6161
raise NotImplementedError()
6262

63+
def __repr__(self) -> str:
64+
return str(self.value)
65+
66+
def __str__(self) -> str:
67+
return str(self.value)
68+
6369

6470
class TimecodeValue(ValidatedValue):
6571
"""Validator for timecode values in seconds (100.0), frames (100), or HH:MM:SS.
@@ -75,12 +81,6 @@ def __init__(self, value: Union[int, float, str]):
7581
def value(self) -> Union[int, float, str]:
7682
return self._value
7783

78-
def __repr__(self) -> str:
79-
return str(self.value)
80-
81-
def __str__(self) -> str:
82-
return str(self.value)
83-
8484
@staticmethod
8585
def from_config(config_value: str, default: "TimecodeValue") -> "TimecodeValue":
8686
try:
@@ -121,12 +121,6 @@ def max_val(self) -> Union[int, float]:
121121
"""Maximum value of the range."""
122122
return self._max_val
123123

124-
def __repr__(self) -> str:
125-
return str(self.value)
126-
127-
def __str__(self) -> str:
128-
return str(self.value)
129-
130124
@staticmethod
131125
def from_config(config_value: str, default: "RangeValue") -> "RangeValue":
132126
try:
@@ -163,9 +157,6 @@ def __init__(self, value: Union[str, ContentDetector.Components]):
163157
def value(self) -> Tuple[float, float, float, float]:
164158
return self._value
165159

166-
def __repr__(self) -> str:
167-
return str(self.value)
168-
169160
def __str__(self) -> str:
170161
return "%.3f, %.3f, %.3f, %.3f" % self.value
171162

@@ -199,9 +190,6 @@ def __init__(self, value: int):
199190
def value(self) -> int:
200191
return self._value
201192

202-
def __repr__(self) -> str:
203-
return str(self.value)
204-
205193
def __str__(self) -> str:
206194
if self.value is None:
207195
return "auto"
@@ -217,6 +205,42 @@ def from_config(config_value: str, default: "KernelSizeValue") -> "KernelSizeVal
217205
) from ex
218206

219207

208+
class EscapedString(ValidatedValue):
209+
"""Strings that can contain escape sequences, e.g. the literal \n."""
210+
211+
def __init__(self, value: str, length_limit: int = 0):
212+
self._value = value.encode("utf-8").decode("unicode_escape")
213+
if length_limit and len(self._value) > length_limit:
214+
raise OptionParseFailure(f"Value must be no longer than {length_limit} characters.")
215+
216+
@property
217+
def value(self) -> str:
218+
"""Get the value after validation."""
219+
return self._value
220+
221+
@staticmethod
222+
def from_config(
223+
config_value: str, default: "EscapedString", length_limit: int = 0
224+
) -> "EscapedString":
225+
try:
226+
return EscapedString(config_value, length_limit)
227+
except (UnicodeDecodeError, UnicodeEncodeError) as ex:
228+
raise OptionParseFailure(
229+
"Value must be valid UTF-8 string with escape characters."
230+
) from ex
231+
232+
233+
class EscapedChar(EscapedString):
234+
"""Strings that can contain escape sequences but can be a maximum of 1 character in length."""
235+
236+
def __init__(self, value: str):
237+
super().__init__(value, length_limit=1)
238+
239+
@staticmethod
240+
def from_config(config_value: str, default: "EscapedString") -> "EscapedChar":
241+
return EscapedString.from_config(config_value, default, length_limit=1)
242+
243+
220244
class TimecodeFormat(Enum):
221245
"""Format to display timecodes."""
222246

@@ -303,12 +327,12 @@ def format(self, timecode: FrameTimecode) -> str:
303327
},
304328
"list-scenes": {
305329
"cut-format": "timecode",
306-
"col-separator": ",",
330+
"col-separator": EscapedChar(","),
307331
"display-cuts": True,
308332
"display-scenes": True,
309333
"filename": "$VIDEO_NAME-Scenes.csv",
310334
"output": None,
311-
"row-separator": "\n",
335+
"row-separator": EscapedString("\n"),
312336
"no-output-file": False,
313337
"quiet": False,
314338
"skip-cuts": False,

scenedetect/_cli/context.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ def handle_options(
215215
raise click.Abort()
216216

217217
if self.config.config_dict:
218-
logger.debug("Current configuration:\n%s", str(self.config.config_dict))
218+
logger.debug("Current configuration:\n%s", str(self.config.config_dict).encode("utf-8"))
219219

220220
logger.debug("Parsing program options.")
221221
if stats is not None and frame_skip:

scenedetect/scene_manager.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ def write_scene_list(
218218
cut_list: Optional[CutList] = None,
219219
col_separator: str = ",",
220220
row_separator: str = "\n",
221-
) -> None:
221+
):
222222
"""Writes the given list of scenes to an output file handle in CSV format.
223223
224224
Arguments:
@@ -229,8 +229,8 @@ def write_scene_list(
229229
cut_list: Optional list of FrameTimecode objects denoting the cut list (i.e. the frames
230230
in the video that need to be split to generate individual scenes). If not specified,
231231
the cut list is generated using the start times of each scene following the first one.
232-
delimiter: Delimiter to use between values. Must be single character.
233-
lineterminator: Line terminator to use between rows.
232+
col_separator: Delimiter to use between values. Must be single character.
233+
row_separator: Line terminator to use between rows.
234234
235235
Raises:
236236
TypeError: "delimiter" must be a 1-character string

tests/test_cli.py

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -294,24 +294,90 @@ def test_cli_list_scenes(tmp_path: Path):
294294
)
295295
== 0
296296
)
297-
# Add statsfile
297+
output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}-Scenes.csv")
298+
assert os.path.exists(output_path)
299+
EXPECTED_CSV_OUTPUT = """Timecode List:,00:00:03.754
300+
Scene Number,Start Frame,Start Timecode,Start Time (seconds),End Frame,End Timecode,End Time (seconds),Length (frames),Length (timecode),Length (seconds)
301+
1,49,00:00:02.002,2.002,90,00:00:03.754,3.754,42,00:00:01.752,1.752
302+
2,91,00:00:03.754,3.754,144,00:00:06.006,6.006,54,00:00:02.252,2.252
303+
"""
304+
assert output_path.read_text() == EXPECTED_CSV_OUTPUT
305+
306+
307+
def test_cli_list_scenes_skip_cuts(tmp_path: Path):
308+
"""Test `list-scenes` command with the -s/--skip-cuts option for RFC 4180 compliance."""
309+
# Regular invocation
298310
assert (
299311
invoke_scenedetect(
300-
"-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} list-scenes",
312+
"-i {VIDEO} time {TIME} {DETECTOR} list-scenes -s",
301313
output_dir=tmp_path,
302314
)
303315
== 0
304316
)
305-
# Suppress output file
317+
output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}-Scenes.csv")
318+
assert os.path.exists(output_path)
319+
EXPECTED_CSV_OUTPUT = """Scene Number,Start Frame,Start Timecode,Start Time (seconds),End Frame,End Timecode,End Time (seconds),Length (frames),Length (timecode),Length (seconds)
320+
1,49,00:00:02.002,2.002,90,00:00:03.754,3.754,42,00:00:01.752,1.752
321+
2,91,00:00:03.754,3.754,144,00:00:06.006,6.006,54,00:00:02.252,2.252
322+
"""
323+
assert output_path.read_text() == EXPECTED_CSV_OUTPUT
324+
325+
326+
def test_cli_list_scenes_no_output(tmp_path: Path):
327+
"""Test `list-scenes` command with the -n flag."""
328+
output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}-Scenes.csv")
306329
assert (
307330
invoke_scenedetect(
308331
"-i {VIDEO} time {TIME} {DETECTOR} list-scenes -n",
309332
output_dir=tmp_path,
310333
)
311334
== 0
312335
)
313-
# TODO: Check for output files from regular invocation.
314-
# TODO: Delete scene list and ensure is not recreated using -n.
336+
assert not os.path.exists(output_path)
337+
338+
339+
def test_cli_list_scenes_custom_delimiter(tmp_path: Path):
340+
"""Test `list-scenes` command with custom delimiters set in a config file."""
341+
config_path = tmp_path.joinpath("config.cfg")
342+
config_path.write_text("""
343+
[list-scenes]
344+
col-separator = |
345+
row-separator = \\t
346+
""")
347+
assert (
348+
invoke_scenedetect(
349+
f"-i {{VIDEO}} -c {config_path} time {{TIME}} {{DETECTOR}} list-scenes",
350+
output_dir=tmp_path,
351+
)
352+
== 0
353+
)
354+
output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}-Scenes.csv")
355+
assert os.path.exists(output_path)
356+
EXPECTED_CSV_OUTPUT = """Timecode List:,00:00:03.754
357+
Scene Number,Start Frame,Start Timecode,Start Time (seconds),End Frame,End Timecode,End Time (seconds),Length (frames),Length (timecode),Length (seconds)
358+
1,49,00:00:02.002,2.002,90,00:00:03.754,3.754,42,00:00:01.752,1.752
359+
2,91,00:00:03.754,3.754,144,00:00:06.006,6.006,54,00:00:02.252,2.252
360+
"""
361+
EXPECTED_CSV_OUTPUT = EXPECTED_CSV_OUTPUT.replace(",", "|").replace("\n", "\t")
362+
assert output_path.read_text() == EXPECTED_CSV_OUTPUT
363+
364+
365+
def test_cli_list_scenes_rejects_multichar_col_separator(tmp_path: Path):
366+
"""Test `list-scenes` command with custom delimiters set in a config file."""
367+
config_path = tmp_path.joinpath("config.cfg")
368+
config_path.write_text("""
369+
[list-scenes]
370+
col-separator = ||
371+
""")
372+
assert (
373+
invoke_scenedetect(
374+
f"-i {{VIDEO}} -c {config_path} time {{TIME}} {{DETECTOR}} list-scenes",
375+
output_dir=tmp_path,
376+
)
377+
!= 0
378+
)
379+
output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}-Scenes.csv")
380+
assert not os.path.exists(output_path)
315381

316382

317383
@pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available")

0 commit comments

Comments
 (0)