diff --git a/README.md b/README.md index e16b05bc..94deb39b 100644 --- a/README.md +++ b/README.md @@ -119,12 +119,12 @@ Example: `osxphotos help export` Usage: osxphotos export [OPTIONS] [PHOTOS_LIBRARY]... DEST Export photos from the Photos database. Export path DEST is required. - Optionally, query the Photos database using 1 or more search options; if - more than one option is provided, they are treated as "AND" (e.g. search + Optionally, query the Photos database using 1 or more search options; if + more than one option is provided, they are treated as "AND" (e.g. search for photos matching all options). If no query options are provided, all photos will be exported. By default, all versions of all photos will be exported including edited versions, live photo movies, burst photos, and - associated raw images. See --skip-edited, --skip-live, --skip-bursts, and + associated raw images. See --skip-edited, --skip-live, --skip-bursts, and --skip-raw options to modify this behavior. Options: @@ -265,6 +265,79 @@ Options: photos if the raw photo does not have an associated jpeg image (e.g. the raw file was imported to Photos without a jpeg preview). + --current-name Use photo's current filename instead of + original filename for export. Note: + Starting with Photos 5, all photos are + renamed upon import. By default, photos are + exported with the the original name they had + before import. + --convert-to-jpeg Convert all non-jpeg images (e.g. raw, HEIC, + PNG, etc) to JPEG upon export. Only works + if your Mac has a GPU. + --jpeg-quality FLOAT RANGE Value in range 0.0 to 1.0 to use with + --convert-to-jpeg. A value of 1.0 specifies + best quality, a value of 0.0 specifies + maximum compression. Defaults to 1.0. + --download-missing Attempt to download missing photos from + iCloud. The current implementation uses + Applescript to interact with Photos to + export the photo which will force Photos to + download from iCloud if the photo does not + exist on disk. This will be slow and will + require internet connection. This obviously + only works if the Photos library is synched + to iCloud. Note: --download-missing does + not currently export all burst images; only + the primary photo will be exported-- + associated burst images will be skipped. + --sidecar FORMAT Create sidecar for each photo exported; + valid FORMAT values: xmp, json; --sidecar + json: create JSON sidecar useable by + exiftool (https://exiftool.org/) The sidecar + file can be used to apply metadata to the + file with exiftool, for example: "exiftool + -j=photoname.jpg.json photoname.jpg" The + sidecar file is named in format + photoname.ext.json --sidecar xmp: create + XMP sidecar used by Adobe Lightroom, etc.The + sidecar file is named in format + photoname.ext.xmpThe XMP sidecar exports the + following tags: Description, Title, + Keywords/Tags, Subject (set to Keywords + + PersonInImage), PersonInImage, CreateDate, + ModifyDate, GPSLongitude. For a list of tags + exported in the JSON sidecar, see + --exiftool. + --exiftool Use exiftool to write metadata directly to + exported photos. To use this option, + exiftool must be installed and in the path. + exiftool may be installed from + https://exiftool.org/. Cannot be used with + --export-as-hardlink. Writes the following + metadata: EXIF:ImageDescription, + XMP:Description (see also --description- + template); XMP:Title; XMP:TagsList, + IPTC:Keywords (see also --keyword-template, + --person-keyword, --album-keyword); + XMP:Subject (set to keywords + person in + image to mirror Photos' behavior); + XMP:PersonInImage; EXIF:GPSLatitudeRef; + EXIF:GPSLongitudeRef; EXIF:GPSLatitude; + EXIF:GPSLongitude; EXIF:GPSPosition; + EXIF:DateTimeOriginal; + EXIF:OffsetTimeOriginal; EXIF:ModifyDate + (see --ignore-date-modified); + IPTC:DateCreated; IPTC:TimeCreated; (video + files only): QuickTime:CreationDate (UTC); + QuickTime:ModifyDate (UTC) (see also + --ignore-date-modified); + QuickTime:GPSCoordinates; + UserData:GPSCoordinates. + --ignore-date-modified If used with --exiftool or --sidecar, will + ignore the photo modification date and set + EXIF:ModifyDate to EXIF:DateTimeOriginal; + this is consistent with how Photos handles + the EXIF:ModifyDate tag. --person-keyword Use person in image as keyword/tag when exporting metadata. --album-keyword Use album name as keyword/tag when exporting @@ -291,53 +364,6 @@ Options: could specify --description-template "{descr} exported with osxphotos on {today.date}" See Templating System below. - --current-name Use photo's current filename instead of - original filename for export. Note: - Starting with Photos 5, all photos are - renamed upon import. By default, photos are - exported with the the original name they had - before import. - --convert-to-jpeg Convert all non-jpeg images (e.g. raw, HEIC, - PNG, etc) to JPEG upon export. Only works - if your Mac has a GPU. - --jpeg-quality FLOAT RANGE Value in range 0.0 to 1.0 to use with - --convert-to-jpeg. A value of 1.0 specifies - best quality, a value of 0.0 specifies - maximum compression. Defaults to 1.0. - --sidecar FORMAT Create sidecar for each photo exported; - valid FORMAT values: xmp, json; --sidecar - json: create JSON sidecar useable by - exiftool (https://exiftool.org/) The sidecar - file can be used to apply metadata to the - file with exiftool, for example: "exiftool - -j=photoname.json photoname.jpg" The sidecar - file is named in format photoname.json - --sidecar xmp: create XMP sidecar used by - Adobe Lightroom, etc.The sidecar file is - named in format photoname.xmp - --download-missing Attempt to download missing photos from - iCloud. The current implementation uses - Applescript to interact with Photos to - export the photo which will force Photos to - download from iCloud if the photo does not - exist on disk. This will be slow and will - require internet connection. This obviously - only works if the Photos library is synched - to iCloud. Note: --download-missing does - not currently export all burst images; only - the primary photo will be exported-- - associated burst images will be skipped. - --exiftool Use exiftool to write metadata directly to - exported photos. To use this option, - exiftool must be installed and in the path. - exiftool may be installed from - https://exiftool.org/. Cannot be used with - --export-as-hardlink. - --ignore-date-modified If used with --exiftool or --sidecar, will - ignore the photo modification date and set - EXIF:ModifyDate to EXIF:DateTimeOriginal; - this is consistent with how Photos handles - the EXIF:ModifyDate tag. --directory DIRECTORY Optional template for specifying name of output directory in the form '{name,DEFAULT}'. See below for additional @@ -379,6 +405,11 @@ Options: default AppleScript interface. --report REPORTNAME.CSV Write a CSV formatted report of all files that were exported. + --cleanup Cleanup export directory by deleting any + files which were not included in this export + set. For example, photos which had + previously been exported and were + subsequently deleted in Photos. -h, --help Show this message and exit. ** Export ** diff --git a/osxphotos/__main__.py b/osxphotos/__main__.py index 83af960b..c48800d8 100644 --- a/osxphotos/__main__.py +++ b/osxphotos/__main__.py @@ -1436,6 +1436,12 @@ def query( help="Write a CSV formatted report of all files that were exported.", type=click.Path(), ) +@click.option( + "--cleanup", + is_flag=True, + help="Cleanup export directory by deleting any files which were not included in this export set. " + "For example, photos which had previously been exported and were subsequently deleted in Photos.", +) @DB_ARGUMENT @click.argument("dest", nargs=1, type=click.Path(exists=True)) @click.pass_obj @@ -1530,6 +1536,7 @@ def export( use_photos_export, use_photokit, report, + cleanup, ): """Export photos from the Photos database. Export path DEST is required. @@ -1550,6 +1557,8 @@ def export( click.echo(f"DEST {dest} must be valid path", err=True) raise click.Abort() + dest = str(pathlib.Path(dest).resolve()) + if report and os.path.isdir(report): click.echo(f"report is a directory, must be file name", err=True) raise click.Abort() @@ -1579,6 +1588,7 @@ def export( (shared, not_shared), (has_comment, no_comment), (has_likes, no_likes), + (export_as_hardlink, cleanup), ] if any(all(bb) for bb in exclusive): click.echo("Incompatible export options", err=True) @@ -1890,7 +1900,6 @@ def export( results_missing.extend(results.missing) results_error.extend(results.error) - stop_time = time.perf_counter() # print summary results # print(f"results_exported: {results_exported}") # print(f"results_new: {results_new}") @@ -1899,11 +1908,36 @@ def export( # print(f"results_exif_updated: {results_exif_updated}") # print(f"results_touched: {results_touched}") # print(f"results_converted: {results_converted}") - # print(f"results_sidecar_json: {results_sidecar_json}") - # print(f"results_sidecar_xmp: {results_sidecar_xmp}") + # print(f"results_sidecar_json_written: {results_sidecar_json_written}") + # print(f"results_sidecar_json_skipped: {results_sidecar_json_skipped}") + # print(f"results_sidecar_xmp_written: {results_sidecar_xmp_written}") + # print(f"results_sidecar_xmp_skipped: {results_sidecar_xmp_skipped}") # print(f"results_missing: {results_missing}") # print(f"results_error: {results_error}") + if cleanup: + all_files = ( + results_exported + + results_skipped + + results_exif_updated + + results_touched + + results_converted + + results_sidecar_json_written + + results_sidecar_json_skipped + + results_sidecar_xmp_written + + results_sidecar_xmp_skipped + # include missing so a file that was already in export directory + # but was missing on --update doesn't get deleted + # (better to have old version than none) + + results_missing + + [str(pathlib.Path(export_db_path).resolve())] + ) + click.echo(f"Cleaning up {dest}") + (cleaned_files, cleaned_dirs) = cleanup_files(dest, all_files, fileutil) + file_str = "files" if cleaned_files != 1 else "file" + dir_str = "directories" if cleaned_dirs != 1 else "directory" + click.echo(f"Deleted: {cleaned_files} {file_str}, {cleaned_dirs} {dir_str}") + if report: verbose(f"Writing export report to {report}") write_export_report( @@ -1942,6 +1976,7 @@ def export( if touch_file: summary += f", touched date: {len(results_touched)}" click.echo(summary) + stop_time = time.perf_counter() click.echo(f"Elapsed time: {(stop_time-start_time):.3f} seconds") else: click.echo("Did not find any photos to export") @@ -2465,68 +2500,6 @@ def export_photo( global VERBOSE VERBOSE = bool(verbose_) - # TODO: if --skip-original-if-edited, it's possible edited version is on disk but - # original is missing, in which case we should download the edited version - if not download_missing: - if photo.ismissing: - space = " " if not verbose_ else "" - verbose(f"{space}Skipping missing photo {photo.original_filename}") - return ExportResults( - exported=[], - new=[], - updated=[], - skipped=[], - exif_updated=[], - touched=[], - converted_to_jpeg=[], - sidecar_json_written=[], - sidecar_json_skipped=[], - sidecar_xmp_written=[], - sidecar_xmp_skipped=[], - missing=[f"{photo.original_filename} ({photo.uuid})"], - error=[], - ) - elif photo.path is None: - space = " " if not verbose_ else "" - verbose( - f"{space}WARNING: photo {photo.original_filename} ({photo.uuid}) is missing but ismissing=False, " - f"skipping {photo.original_filename}" - ) - return ExportResults( - exported=[], - new=[], - updated=[], - skipped=[], - exif_updated=[], - touched=[], - converted_to_jpeg=[], - sidecar_json_written=[], - sidecar_json_skipped=[], - sidecar_xmp_written=[], - sidecar_xmp_skipped=[], - missing=[f"{photo.original_filename} ({photo.uuid})"], - error=[], - ) - elif photo.ismissing and not photo.iscloudasset and not photo.incloud: - verbose( - f"Skipping missing {photo.original_filename}: not iCloud asset or missing from cloud" - ) - return ExportResults( - exported=[], - new=[], - updated=[], - skipped=[], - exif_updated=[], - touched=[], - converted_to_jpeg=[], - sidecar_json_written=[], - sidecar_json_skipped=[], - sidecar_xmp_written=[], - sidecar_xmp_skipped=[], - missing=[f"{photo.original_filename} ({photo.uuid})"], - error=[], - ) - results_exported = [] results_new = [] results_updated = [] @@ -2539,6 +2512,7 @@ def export_photo( results_sidecar_xmp_written = [] results_sidecar_xmp_skipped = [] results_error = [] + results_missing = [] export_original = not (skip_original_if_edited and photo.hasadjustments) @@ -2558,6 +2532,29 @@ def export_photo( f"Edited file for {photo.original_filename} is missing, exporting original" ) + # check for missing photos before downloading + missing_original = False + missing_edited = False + if download_missing: + if ( + (photo.ismissing or photo.path is None) + and not photo.iscloudasset + and not photo.incloud + ): + missing_original = True + if ( + photo.hasadjustments + and photo.path_edited is None + and not photo.iscloudasset + and not photo.incloud + ): + missing_edited = True + else: + if photo.ismissing or photo.path is None: + missing_original = True + if photo.hasadjustments and photo.path_edited is None: + missing_edited = True + filenames = get_filenames_from_template(photo, filename_template, original_name) for filename in filenames: if original_suffix: @@ -2598,73 +2595,73 @@ def export_photo( # export the photo to each path in dest_paths for dest_path in dest_paths: + # TODO: if --skip-original-if-edited, it's possible edited version is on disk but + # original is missing, in which case we should download the edited version if export_original: - try: - export_results = photo.export2( - dest_path, - original_filename, - sidecar_json=sidecar_json, - sidecar_xmp=sidecar_xmp, - live_photo=export_live, - raw_photo=export_raw, - export_as_hardlink=export_as_hardlink, - overwrite=overwrite, - use_photos_export=use_photos_export, - exiftool=exiftool, - no_xattr=no_extended_attributes, - use_albums_as_keywords=album_keyword, - use_persons_as_keywords=person_keyword, - keyword_template=keyword_template, - description_template=description_template, - update=update, - export_db=export_db, - fileutil=fileutil, - dry_run=dry_run, - touch_file=touch_file, - convert_to_jpeg=convert_to_jpeg, - jpeg_quality=jpeg_quality, - ignore_date_modified=ignore_date_modified, - use_photokit=use_photokit, - verbose=verbose, + if missing_original: + space = " " if not verbose_ else "" + verbose(f"{space}Skipping missing photo {photo.original_filename}") + results_missing.append( + str(pathlib.Path(dest_path) / original_filename) ) + else: + try: + export_results = photo.export2( + dest_path, + original_filename, + sidecar_json=sidecar_json, + sidecar_xmp=sidecar_xmp, + live_photo=export_live, + raw_photo=export_raw, + export_as_hardlink=export_as_hardlink, + overwrite=overwrite, + use_photos_export=use_photos_export, + exiftool=exiftool, + no_xattr=no_extended_attributes, + use_albums_as_keywords=album_keyword, + use_persons_as_keywords=person_keyword, + keyword_template=keyword_template, + description_template=description_template, + update=update, + export_db=export_db, + fileutil=fileutil, + dry_run=dry_run, + touch_file=touch_file, + convert_to_jpeg=convert_to_jpeg, + jpeg_quality=jpeg_quality, + ignore_date_modified=ignore_date_modified, + use_photokit=use_photokit, + verbose=verbose, + ) - results_exported.extend(export_results.exported) - results_new.extend(export_results.new) - results_updated.extend(export_results.updated) - results_skipped.extend(export_results.skipped) - results_exif_updated.extend(export_results.exif_updated) - results_touched.extend(export_results.touched) - results_converted.extend(export_results.converted_to_jpeg) - results_sidecar_json_written.extend( - export_results.sidecar_json_written - ) - results_sidecar_json_skipped.extend( - export_results.sidecar_json_skipped - ) - results_sidecar_xmp_written.extend( - export_results.sidecar_xmp_written - ) - results_sidecar_xmp_skipped.extend( - export_results.sidecar_xmp_skipped - ) + results_exported.extend(export_results.exported) + results_new.extend(export_results.new) + results_updated.extend(export_results.updated) + results_skipped.extend(export_results.skipped) + results_exif_updated.extend(export_results.exif_updated) + results_touched.extend(export_results.touched) + results_converted.extend(export_results.converted_to_jpeg) + results_sidecar_json_written.extend( + export_results.sidecar_json_written + ) + results_sidecar_json_skipped.extend( + export_results.sidecar_json_skipped + ) + results_sidecar_xmp_written.extend( + export_results.sidecar_xmp_written + ) + results_sidecar_xmp_skipped.extend( + export_results.sidecar_xmp_skipped + ) - if verbose_: - for exported in export_results.exported: - verbose(f"Exported {exported}") - for new in export_results.new: - verbose(f"Exported new file {new}") - for updated in export_results.updated: - verbose(f"Exported updated file {updated}") - for skipped in export_results.skipped: - verbose(f"Skipped up to date file {skipped}") - for touched in export_results.touched: - verbose(f"Touched date on file {touched}") - except Exception: - click.echo( - f"Error exporting photo {photo.original_filename} ({photo.filename}) as {original_filename}", - err=True, - ) - results_error.extend(dest) + except Exception: + click.echo( + f"Error exporting photo {photo.original_filename} ({photo.filename}) as {original_filename}", + err=True, + ) + results_error.extend( + str(pathlib.Path(dest) / original_filename) + ) else: verbose(f"Skipping original version of {photo.original_filename}") @@ -2673,23 +2670,26 @@ def export_photo( if export_edited and photo.hasadjustments: # if download_missing and the photo is missing or path doesn't exist, # try to download with Photos - if not download_missing and photo.path_edited is None: - verbose(f"Skipping missing edited photo for {filename}") + + edited_filename = pathlib.Path(filename) + # check for correct edited suffix + if photo.path_edited is not None: + edited_ext = pathlib.Path(photo.path_edited).suffix else: - edited_filename = pathlib.Path(filename) - # check for correct edited suffix - if photo.path_edited is not None: - edited_ext = pathlib.Path(photo.path_edited).suffix - else: - # use filename suffix which might be wrong, - # will be corrected by use_photos_export - edited_ext = pathlib.Path(photo.filename).suffix - edited_filename = ( - f"{edited_filename.stem}{edited_suffix}{edited_ext}" - ) - verbose( - f"Exporting edited version of {photo.original_filename} ({photo.filename}) as {edited_filename}" + # use filename suffix which might be wrong, + # will be corrected by use_photos_export + edited_ext = pathlib.Path(photo.filename).suffix + edited_filename = f"{edited_filename.stem}{edited_suffix}{edited_ext}" + verbose( + f"Exporting edited version of {photo.original_filename} ({photo.filename}) as {edited_filename}" + ) + if missing_edited: + space = " " if not verbose_ else "" + verbose(f"{space}Skipping missing edited photo for {filename}") + results_missing.append( + str(pathlib.Path(dest_path) / edited_filename) ) + else: try: export_results_edited = photo.export2( dest_path, @@ -2740,23 +2740,26 @@ def export_photo( export_results_edited.sidecar_xmp_skipped ) - if verbose_: - for exported in export_results_edited.exported: - verbose(f"Exported {exported}") - for new in export_results_edited.new: - verbose(f"Exported new file {new}") - for updated in export_results_edited.updated: - verbose(f"Exported updated file {updated}") - for skipped in export_results_edited.skipped: - verbose(f"Skipped up to date file {skipped}") - for touched in export_results_edited.touched: - verbose(f"Touched date on file {touched}") except Exception: click.echo( f"Error exporting photo {filename} as {edited_filename}", err=True, ) - results_error.extend(dest) + results_error.extend(str(pathlib.Path(dest) / edited_filename)) + + if verbose_: + if update: + for new in results_new: + verbose(f"Exported new file {new}") + for updated in results_updated: + verbose(f"Exported updated file {updated}") + for skipped in results_skipped: + verbose(f"Skipped up to date file {skipped}") + else: + for exported in results_exported: + verbose(f"Exported {exported}") + for touched in results_touched: + verbose(f"Touched date on file {touched}") return ExportResults( exported=results_exported, @@ -2770,7 +2773,7 @@ def export_photo( sidecar_json_skipped=results_sidecar_json_skipped, sidecar_xmp_written=results_sidecar_xmp_written, sidecar_xmp_skipped=results_sidecar_xmp_skipped, - missing=[], + missing=results_missing, error=results_error, ) @@ -3043,5 +3046,40 @@ def write_export_report( raise click.Abort() +def cleanup_files(dest_path, files_to_keep, fileutil): + """ cleanup dest_path by deleting and files and empty directories + not in files_to_keep + + Args: + dest_path: path to directory to clean + files_to_keep: list of full file paths to keep (not delete) + fileutile: FileUtil object + + Returns: + tuple of (number of files deleted, number of directories deleted) + """ + keepers = {filename.lower(): 1 for filename in files_to_keep} + + deleted_files = 0 + for p in pathlib.Path(dest_path).rglob("*"): + path = str(p).lower() + if p.is_file() and path not in keepers: + verbose(f"Deleting {p}") + fileutil.unlink(p) + deleted_files += 1 + + # delete empty directories + deleted_dirs = 0 + for p in pathlib.Path(dest_path).rglob("*"): + path = str(p).lower() + # if directory and directory is empty + if p.is_dir() and not next(p.iterdir(), False): + verbose(f"Deleting empty directory {p}") + fileutil.rmdir(p) + deleted_dirs += 1 + + return (deleted_files, deleted_dirs) + + if __name__ == "__main__": cli() # pylint: disable=no-value-for-parameter diff --git a/osxphotos/_version.py b/osxphotos/_version.py index ea1d6bde..8c4789cc 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,4 +1,4 @@ """ version info """ -__version__ = "0.37.5" +__version__ = "0.37.6" diff --git a/osxphotos/fileutil.py b/osxphotos/fileutil.py index 48577e09..ed06a594 100644 --- a/osxphotos/fileutil.py +++ b/osxphotos/fileutil.py @@ -9,6 +9,7 @@ from abc import ABC, abstractmethod from .imageconverter import ImageConverter + class FileUtilABC(ABC): """ Abstract base class for FileUtil """ @@ -27,6 +28,11 @@ class FileUtilABC(ABC): def unlink(cls, dest): pass + @classmethod + @abstractmethod + def rmdir(cls, dest): + pass + @classmethod @abstractmethod def utime(cls, path, times): @@ -114,6 +120,14 @@ class FileUtilMacOS(FileUtilABC): else: os.unlink(filepath) + @classmethod + def rmdir(cls, dirpath): + """ remove directory filepath; dirpath must be empty """ + if isinstance(dirpath, pathlib.Path): + dirpath.rmdir() + else: + os.rmdir(dirpath) + @classmethod def utime(cls, path, times): """ Set the access and modified time of path. """ @@ -164,7 +178,7 @@ class FileUtilMacOS(FileUtilABC): def file_sig(cls, f1): """ return os.stat signature for file f1 """ return cls._sig(os.stat(f1)) - + @classmethod def convert_to_jpeg(cls, src_file, dest_file, compression_quality=1.0): """ converts image file src_file to jpeg format as dest_file @@ -178,7 +192,9 @@ class FileUtilMacOS(FileUtilABC): True if success, otherwise False """ converter = ImageConverter() - return converter.write_jpeg(src_file, dest_file, compression_quality=compression_quality) + return converter.write_jpeg( + src_file, dest_file, compression_quality=compression_quality + ) @staticmethod def _sig(st): @@ -189,6 +205,7 @@ class FileUtilMacOS(FileUtilABC): # use int(st.st_mtime) because ditto does not copy fractional portion of mtime return (stat.S_IFMT(st.st_mode), st.st_size, int(st.st_mtime)) + class FileUtil(FileUtilMacOS): """ Various file utilities """ @@ -228,6 +245,10 @@ class FileUtilNoOp(FileUtil): def unlink(cls, dest): cls.verbose(f"unlink: {dest}") + @classmethod + def rmdir(cls, dest): + cls.verbose(f"rmdir: {dest}") + @classmethod def utime(cls, path, times): cls.verbose(f"utime: {path}, {times}") diff --git a/tests/test_cli.py b/tests/test_cli.py index 3d9a2aa3..10871d0a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3272,9 +3272,9 @@ def test_export_then_hardlink(): def test_export_dry_run(): """ test export with dry-run flag """ - import glob import os import os.path + import re import osxphotos from osxphotos.__main__ import export @@ -3288,7 +3288,7 @@ def test_export_dry_run(): assert result.exit_code == 0 assert "Processed: 7 photos, exported: 8, missing: 1, error: 0" in result.output for filepath in CLI_EXPORT_FILENAMES: - assert f"Exported {filepath}" in result.output + assert re.search(r"Exported.*" + f"{filepath}", result.output) assert not os.path.isfile(filepath) @@ -3340,10 +3340,10 @@ def test_export_update_edits_dry_run(): def test_export_directory_template_1_dry_run(): """ test export using directory template with dry-run flag """ - import glob import locale import os import os.path + import re import osxphotos from osxphotos.__main__ import export @@ -3368,7 +3368,7 @@ def test_export_directory_template_1_dry_run(): assert "exported: 8" in result.output workdir = os.getcwd() for filepath in CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES1: - assert f"Exported {filepath}" in result.output + assert re.search(r"Exported.*" + f"{filepath}", result.output) assert not os.path.isfile(os.path.join(workdir, filepath)) @@ -3914,3 +3914,73 @@ def test_export_missing_not_download_missing(): ) assert result.exit_code != 0 assert "Aborted!" in result.output + + +def test_export_cleanup(): + """ test export with --cleanup flag """ + import pathlib + from osxphotos.__main__ import export + + runner = CliRunner() + cwd = os.getcwd() + # pylint: disable=not-context-manager + with runner.isolated_filesystem(): + result = runner.invoke(export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V"]) + assert result.exit_code == 0 + + # create 2 files and a directory + with open("delete_me.txt", "w") as fd: + fd.write("delete me!") + os.mkdir("./foo") + with open("foo/delete_me_too.txt", "w") as fd: + fd.write("delete me too!") + + assert pathlib.Path("./delete_me.txt").is_file() + # run cleanup with dry-run + result = runner.invoke( + export, + [ + os.path.join(cwd, CLI_PHOTOS_DB), + ".", + "-V", + "--update", + "--cleanup", + "--dry-run", + ], + ) + assert "Deleted: 2 files, 0 directories" in result.output + assert pathlib.Path("./delete_me.txt").is_file() + assert pathlib.Path("./foo/delete_me_too.txt").is_file() + + # run cleanup without dry-run + result = runner.invoke( + export, + [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--update", "--cleanup"], + ) + assert "Deleted: 2 files, 1 directory" in result.output + assert not pathlib.Path("./delete_me.txt").is_file() + assert not pathlib.Path("./foo/delete_me_too.txt").is_file() + + +def test_export_cleanup_export_as_hardling(): + """ test export with incompatible option """ + import os + import os.path + from osxphotos.__main__ import export + + runner = CliRunner() + cwd = os.getcwd() + # pylint: disable=not-context-manager + with runner.isolated_filesystem(): + result = runner.invoke( + export, + [ + os.path.join(cwd, CLI_PHOTOS_DB), + ".", + "-V", + "--export-as-hardlink", + "--cleanup", + ], + ) + assert "Incompatible export options" in result.output + diff --git a/tests/test_fileutil.py b/tests/test_fileutil.py index 474d04b5..1109f400 100644 --- a/tests/test_fileutil.py +++ b/tests/test_fileutil.py @@ -73,6 +73,18 @@ def test_unlink_file(): assert not os.path.isfile(dest) +def test_rmdir(): + import os.path + import tempfile + from osxphotos.fileutil import FileUtil + + temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_") + dir_name = temp_dir.name + assert os.path.isdir(dir_name) + FileUtil.rmdir(dir_name) + assert not os.path.isdir(dir_name) + + @pytest.mark.skipif( "OSXPHOTOS_TEST_CONVERT" not in os.environ, reason="Skip if running in Github actions, no GPU.", @@ -90,6 +102,7 @@ def test_convert_to_jpeg(): assert FileUtil.convert_to_jpeg(imgfile, outfile) assert outfile.is_file() + @pytest.mark.skipif( "OSXPHOTOS_TEST_CONVERT" not in os.environ, reason="Skip if running in Github actions, no GPU.",