Skip to content

[output] Support paths and file handles in scene-list writers - #570

Open
MuhammadBilalKhan267 wants to merge 3 commits into
Breakthrough:mainfrom
MuhammadBilalKhan267:feature/567-output-decorator
Open

[output] Support paths and file handles in scene-list writers#570
MuhammadBilalKhan267 wants to merge 3 commits into
Breakthrough:mainfrom
MuhammadBilalKhan267:feature/567-output-decorator

Conversation

@MuhammadBilalKhan267

Copy link
Copy Markdown
Contributor

Closes #567.

  • Add a shared decorator for opening and closing output paths.
  • Support str, Path, and open text files for EDL, FCPXML, FCP7 XML, and OTIO output.
  • Preserve CSV newline handling.
  • Add tests and update API documentation and changelog.

Tests: 382 passed, 2 skipped.

@Breakthrough Breakthrough left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for getting started with this one, there's a lot of moving parts here.

Comment thread scenedetect/output/__init__.py Outdated
if not isinstance(output_file, (str, Path)):
return func(*args, **kwargs)

with open(output_file, "w", newline=newline) as file_handle:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will open and truncate the file before the writer operates, which means any exceptions during generation leave an empty file or clobber an existing one.

Can we render the contents of the file in memory first, only opening + writing it to disk once the body is constructed? Most writers build the whole document in memory so this should be relatively cheap.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had noticed this earlier but followed the pattern introduced in #566, where a path is opened in write mode before delegating to the writer. Changed now.

Comment thread scenedetect/output/__init__.py Outdated
def wrapper(*args: ty.Any, **kwargs: ty.Any):
output_file = args[0] if args else kwargs[parameter_name]

if not isinstance(output_file, (str, Path)):

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't necessarily work if the OS uses bytes for paths. Previously we coerced the input into Path(output_path) to ensure the type - consider using isinstance(output_file, (str, bytes, os.PathLike)) here and annotating the writers as StrPath | ty.TextIO.

Comment thread scenedetect/output/__init__.py Outdated
if not isinstance(output_file, (str, Path)):
return func(*args, **kwargs)

with open(output_file, "w", newline=newline) as file_handle:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to add an explicit encoding here, UTF-8 seems like a good candidate (set encoding="utf-8" here).

Comment thread scenedetect/output/__init__.py Outdated
return decorator


@_open_output_file(parameter_name="output_csv_file", newline="")

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CSV writer passes newline="" but the other four open with the default value, so on Windows the same writer could emit CRLF when given a path, but then LF when given a handle.

Path-mode CRLF was pre-existing, but we should make the behaviour consistent. Since every writer emits its own line endings, maybe we could just open with newline="" unconditionally in the decorator and drop newline for this special case. Thoughts?

def decorator(func: _F) -> _F:
@functools.wraps(func)
def wrapper(*args: ty.Any, **kwargs: ty.Any):
output_file = args[0] if args else kwargs[parameter_name]

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use a single checked source of truth for the filename somehow? Or just drop the parameter name entirely and assert at decoration time that the first param is the file always?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Now we get the name of the file parameter from the function signature.

Comment thread scenedetect/output/__init__.py Outdated
row_separator=row_separator,
)
return
output_csv_file = ty.cast(ty.TextIO, output_csv_file)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we just assert the instance is of type X instead of casting here and elsewhere? (e.g .assert not isinstance(output_path, (str, os.PathLike)))

This avoids type errors when someone passes in the wrong type.

Also: some writers reassign the arg in place, while the other four introduce a new output_file name. Might be worth consistency here to guide a future API change, since I do think the current arg name is too verbose. That's for another time though of course.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The decorator converts paths to StringIO before calling the writer and rejects objects without write(). The suggested assertion would therefore always pass. ty.cast() only tells Pyright that this is a text file.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assertions about meta attributes should also fulfill Pyright's resolution. The reason I prefer the assertion is that it ensures we fail loudly if for whatever reason someone tries to change it incorrectly.

Comment thread scenedetect/output/__init__.py Outdated
# `scenedetect` is imported lazily to avoid a circular import at module load.
import scenedetect
logger.info(
"Writing scenes in EDL format to %s",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do the getattr thing a lot here, can we just add this to the decorator to avoid repeating it?

Comment thread scenedetect/output/__init__.py Outdated
_F = ty.TypeVar("_F", bound=ty.Callable[..., ty.Any])


def _open_output_file(parameter_name: str, *, newline: str | None = None):

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: consider adding a return type annotation here and for the inner wrapper for consistency with the other definitions in this file.

Comment thread tests/test_output.py Outdated
assert "TITLE:" in output_path.read_text()


def test_write_scene_list_edl_accepts_path(tmp_path: Path):

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The four new *_accepts_path tests seem to be subsets of existing tests directly above them. The remaining eight could also be parameterized to avoid so much code duplication. Consider deleting the _accepts_path tests and instead parameterize over the target kind (file handle, string, os.Path, etc). See other uses of @pytest.mark.parametrize in this file for examples.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I initially followed the separate file-handle/string/Path test pattern from #566. Changed now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Use Decorator Pattern for Consistent Output Handling

2 participants