Port to non-MacOS platforms (#1026)

* Port to non-MacOS platforms

* Keep NFD normalization on macOS

* Update locale_util.py

Fix lint error from ruff (runs in CI)

* Update query.py

click.Option first arg needs to be a list (different than click.option)

* Dynamically normalize Unicode paths in test

* Fix missing import

---------

Co-authored-by: Rhet Turnbull <rturnbull@gmail.com>
This commit is contained in:
dvdkon 2023-05-07 15:55:56 +02:00 committed by GitHub
parent 0c85298c03
commit ca3da647f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 726 additions and 360 deletions

View File

@ -16,7 +16,6 @@ from .momentinfo import MomentInfo
from .personinfo import PersonInfo
from .photoexporter import ExportOptions, ExportResults, PhotoExporter
from .photoinfo import PhotoInfo
from .photosalbum import PhotosAlbum, PhotosAlbumPhotoScript
from .photosdb import PhotosDB
from .photosdb._photosdb_process_comments import CommentInfo, LikeInfo
from .phototables import PhotoTables
@ -25,6 +24,10 @@ from .placeinfo import PlaceInfo
from .queryoptions import QueryOptions
from .scoreinfo import ScoreInfo
from .searchinfo import SearchInfo
from .utils import is_macos
if is_macos:
from .photosalbum import PhotosAlbum, PhotosAlbumPhotoScript
# configure logging; every module in osxphotos should use this logger
logging.basicConfig(

View File

@ -5,6 +5,7 @@ import sys
from rich import print
from rich.traceback import install as install_traceback
from osxphotos.utils import is_macos
from osxphotos.debug import (
debug_breakpoint,
debug_watch,
@ -44,9 +45,7 @@ if args.get("--debug", False):
print("Debugging enabled", file=sys.stderr)
from .about import about
from .add_locations import add_locations
from .albums import albums
from .batch_edit import batch_edit
from .cli import cli_main
from .cli_commands import (
abort,
@ -67,7 +66,6 @@ from .export import export
from .exportdb import exportdb
from .grep import grep
from .help import help
from .import_cli import import_cli
from .info import info
from .install_uninstall_run import install, run, uninstall
from .keywords import keywords
@ -76,19 +74,24 @@ from .labels import labels
from .list import _list_libraries, list_libraries
from .orphans import orphans
from .persons import persons
from .photo_inspect import photo_inspect
from .places import places
from .query import query
from .repl import repl
from .show_command import show
from .snap_diff import diff, snap
from .sync import sync
from .theme import theme
from .timewarp import timewarp
from .tutorial import tutorial
from .uuid import uuid
from .version import version
if is_macos:
from .add_locations import add_locations
from .batch_edit import batch_edit
from .import_cli import import_cli
from .photo_inspect import photo_inspect
from .show_command import show
from .sync import sync
from .timewarp import timewarp
from .uuid import uuid
install_traceback()
__all__ = [

View File

@ -5,11 +5,10 @@ from __future__ import annotations
import datetime
import click
import photoscript
import osxphotos
from osxphotos.queryoptions import IncompatibleQueryOptions, query_options_from_kwargs
from osxphotos.utils import pluralize
from osxphotos.utils import assert_macos, pluralize
from .cli_params import QUERY_OPTIONS, THEME_OPTION, TIMESTAMP_OPTION, VERBOSE_OPTION
from .click_rich_echo import rich_click_echo as echo
@ -18,6 +17,10 @@ from .param_types import TimeOffset
from .rich_progress import rich_progress
from .verbose import get_verbose_console, verbose_print
assert_macos()
import photoscript
def get_location(
photos: list[osxphotos.PhotoInfo], idx: int, window: datetime.timedelta

View File

@ -9,11 +9,15 @@ import json
import sys
import click
import photoscript
import osxphotos
from osxphotos.phototemplate import RenderOptions
from osxphotos.sqlitekvstore import SQLiteKVStore
from osxphotos.utils import assert_macos
assert_macos()
import photoscript
from .cli_commands import echo, echo_error, selection_command, verbose
from .kvstore import kvstore

View File

@ -9,11 +9,10 @@ import click
from osxphotos._constants import PROFILE_SORT_KEYS
from osxphotos._version import __version__
from osxphotos.utils import is_macos
from .about import about
from .add_locations import add_locations
from .albums import albums
from .batch_edit import batch_edit
from .cli_params import DB_OPTION, DEBUG_OPTIONS, JSON_OPTION, VERSION_OPTION
from .common import OSXPHOTOS_HIDDEN
from .debug_dump import debug_dump
@ -24,7 +23,6 @@ from .export import export
from .exportdb import exportdb
from .grep import grep
from .help import help
from .import_cli import import_cli
from .info import info
from .install_uninstall_run import install, run, uninstall
from .keywords import keywords
@ -32,19 +30,24 @@ from .labels import labels
from .list import list_libraries
from .orphans import orphans
from .persons import persons
from .photo_inspect import photo_inspect
from .places import places
from .query import query
from .repl import repl
from .show_command import show
from .snap_diff import diff, snap
from .sync import sync
from .theme import theme
from .timewarp import timewarp
from .tutorial import tutorial
from .uuid import uuid
from .version import version
if is_macos:
from .add_locations import add_locations
from .batch_edit import batch_edit
from .import_cli import import_cli
from .photo_inspect import photo_inspect
from .show_command import show
from .sync import sync
from .timewarp import timewarp
from .uuid import uuid
# Click CLI object & context settings
class CLI_Obj:
@ -106,11 +109,9 @@ def cli_main(ctx, db, json_, profile, profile_sort, **kwargs):
# install CLI commands
for command in [
commands = [
about,
add_locations,
albums,
batch_edit,
debug_dump,
diff,
docs_command,
@ -120,7 +121,6 @@ for command in [
exportdb,
grep,
help,
import_cli,
info,
install,
keywords,
@ -128,19 +128,28 @@ for command in [
list_libraries,
orphans,
persons,
photo_inspect,
places,
query,
repl,
run,
show,
snap,
sync,
theme,
timewarp,
tutorial,
uninstall,
uuid,
version,
]:
]
if is_macos:
commands += [
add_locations,
batch_edit,
import_cli,
photo_inspect,
show,
sync,
timewarp,
uuid,
]
for command in commands:
cli_main.add_command(command)

View File

@ -8,6 +8,8 @@ from typing import Any, Callable
import click
import contextlib
from textwrap import dedent
from ..utils import is_macos
from .common import OSXPHOTOS_HIDDEN, print_version
from .param_types import *
@ -642,6 +644,9 @@ _QUERY_PARAMETERS_DICT = {
),
}
if not is_macos:
del _QUERY_PARAMETERS_DICT["--selected"]
def QUERY_OPTIONS(
wrapped=None, *, exclude: list[str] | None = None

View File

@ -1,19 +1,34 @@
"""Detect dark mode on MacOS >= 10.14"""
"""Detect dark mode on MacOS >= 10.14 or fake it elsewhere"""
import objc
import Foundation
from osxphotos.utils import is_macos
def theme():
with objc.autorelease_pool():
user_defaults = Foundation.NSUserDefaults.standardUserDefaults()
system_theme = user_defaults.stringForKey_("AppleInterfaceStyle")
return "dark" if system_theme == "Dark" else "light"
if is_macos:
import objc
import Foundation
def is_dark_mode():
return theme() == "dark"
def theme():
with objc.autorelease_pool():
user_defaults = Foundation.NSUserDefaults.standardUserDefaults()
system_theme = user_defaults.stringForKey_("AppleInterfaceStyle")
return "dark" if system_theme == "Dark" else "light"
def is_light_mode():
return theme() == "light"
def is_dark_mode():
return theme() == "dark"
def is_light_mode():
return theme() == "light"
else:
def theme():
return "light"
def is_dark_mode():
return theme() == "dark"
def is_light_mode():
return theme() == "light"

View File

@ -12,13 +12,6 @@ import time
from typing import Iterable, List, Optional, Tuple
import click
from osxmetadata import (
MDITEM_ATTRIBUTE_DATA,
MDITEM_ATTRIBUTE_SHORT_NAMES,
OSXMetaData,
Tag,
)
from osxmetadata.constants import _TAGS_NAMES
import osxphotos
from osxphotos._constants import (
@ -47,26 +40,37 @@ from osxphotos.datetime_formatter import DateTimeFormatter
from osxphotos.debug import is_debug
from osxphotos.exiftool import get_exiftool_path
from osxphotos.export_db import ExportDB, ExportDBInMemory
from osxphotos.fileutil import FileUtil, FileUtilNoOp, FileUtilShUtil
from osxphotos.fileutil import FileUtilMacOS, FileUtilNoOp, FileUtilShUtil
from osxphotos.path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath
from osxphotos.photoexporter import ExportOptions, ExportResults, PhotoExporter
from osxphotos.photoinfo import PhotoInfoNone
from osxphotos.photokit import (
check_photokit_authorization,
request_photokit_authorization,
)
from osxphotos.photosalbum import PhotosAlbum
from osxphotos.phototemplate import PhotoTemplate, RenderOptions
from osxphotos.queryoptions import load_uuid_from_file, query_options_from_kwargs
from osxphotos.uti import get_preferred_uti_extension
from osxphotos.utils import (
format_sec_to_hhmmss,
get_macos_version,
is_macos,
normalize_fs_path,
pluralize,
under_test,
)
if is_macos:
from osxmetadata import (
MDITEM_ATTRIBUTE_DATA,
MDITEM_ATTRIBUTE_SHORT_NAMES,
OSXMetaData,
Tag,
)
from osxmetadata.constants import _TAGS_NAMES
from osxphotos.photokit import (
check_photokit_authorization,
request_photokit_authorization,
)
from osxphotos.photosalbum import PhotosAlbum
from .cli_commands import logger
from .cli_params import (
DB_ARGUMENT,
@ -851,7 +855,6 @@ def export(
retry,
save_config,
screenshot,
selected,
selfie,
shared,
sidecar,
@ -883,6 +886,7 @@ def export(
verbose_flag,
xattr_template,
year,
selected=False, # Isn't provided on unsupported platforms
# debug, # debug, watch, breakpoint handled in cli/__init__.py
# watch,
# breakpoint,
@ -1111,7 +1115,10 @@ def export(
verbose(f"osxphotos version: {__version__}")
verbose(f"Python version: {sys.version}")
verbose(f"Platform: {platform.platform()}, {'.'.join(get_macos_version())}")
if is_macos:
verbose(f"Platform: {platform.platform()}, {'.'.join(get_macos_version())}")
else:
verbose(f"Platform: {platform.platform()}")
verbose(f"Verbose level: {verbose_flag}")
# validate options
@ -1325,7 +1332,7 @@ def export(
if ramdb
else ExportDB(dbfile=export_db_path, export_dir=dest)
)
fileutil = FileUtilShUtil if alt_copy else FileUtil
fileutil = FileUtilShUtil if alt_copy or not is_macos else FileUtilMacOS
if verbose:
if export_db.was_created:
@ -1713,7 +1720,7 @@ def export_photo(
keyword_template=None,
description_template=None,
export_db=None,
fileutil=FileUtil,
fileutil=FileUtilShUtil,
dry_run=None,
touch_file=None,
edited_suffix="_edited",

View File

@ -5,7 +5,6 @@ import re
import typing as t
import click
from osxmetadata import MDITEM_ATTRIBUTE_DATA, MDITEM_ATTRIBUTE_SHORT_NAMES
from rich.console import Console
from rich.markdown import Markdown
@ -21,6 +20,10 @@ from osxphotos.phototemplate import (
TEMPLATE_SUBSTITUTIONS_PATHLIB,
get_template_help,
)
from osxphotos.utils import is_macos
if is_macos:
from osxmetadata import MDITEM_ATTRIBUTE_DATA, MDITEM_ATTRIBUTE_SHORT_NAMES
from .click_rich_echo import rich_echo_via_pager
from .color_themes import get_theme
@ -249,68 +252,71 @@ class ExportCommand(click.Command):
+ f"rebuilding the '{OSXPHOTOS_EXPORT_DB}' database."
)
formatter.write("\n")
formatter.write(
rich_text("## Extended Attributes", width=formatter.width, markdown=True)
)
formatter.write("\n")
formatter.write_text(
"""
Some options (currently '--finder-tag-template', '--finder-tag-keywords', '-xattr-template') write
additional metadata accessible by Spotlight to facilitate searching.
For example, --finder-tag-keyword writes all keywords (including any specified by '--keyword-template'
or other options) to Finder tags that are searchable in Spotlight using the syntax: 'tag:tagname'.
For example, if you have images with keyword "Travel" then using '--finder-tag-keywords' you could quickly
find those images in the Finder by typing 'tag:Travel' in the Spotlight search bar.
Finder tags are written to the 'com.apple.metadata:_kMDItemUserTags' extended attribute.
Unlike EXIF metadata, extended attributes do not modify the actual file;
the metadata is written to extended attributes associated with the file and the Spotlight metadata database.
Most cloud storage services do not synch extended attributes.
Dropbox does sync them and any changes to a file's extended attributes
will cause Dropbox to re-sync the files.
The following attributes may be used with '--xattr-template':
"""
)
# build help text from all the attribute names
# passed to click.HelpFormatter.write_dl for formatting
attr_tuples = [
(
rich_text("[bold]Attribute[/bold]", width=formatter.width),
rich_text("[bold]Description[/bold]", width=formatter.width),
if is_macos:
formatter.write(
rich_text("## Extended Attributes", width=formatter.width, markdown=True)
)
]
for attr_key in sorted(EXTENDED_ATTRIBUTE_NAMES):
# get short and long name
attr = MDITEM_ATTRIBUTE_SHORT_NAMES[attr_key]
short_name = MDITEM_ATTRIBUTE_DATA[attr]["short_name"]
long_name = MDITEM_ATTRIBUTE_DATA[attr]["name"]
constant = MDITEM_ATTRIBUTE_DATA[attr]["xattr_constant"]
formatter.write("\n")
formatter.write_text(
"""
Some options (currently '--finder-tag-template', '--finder-tag-keywords', '-xattr-template') write
additional metadata accessible by Spotlight to facilitate searching.
For example, --finder-tag-keyword writes all keywords (including any specified by '--keyword-template'
or other options) to Finder tags that are searchable in Spotlight using the syntax: 'tag:tagname'.
For example, if you have images with keyword "Travel" then using '--finder-tag-keywords' you could quickly
find those images in the Finder by typing 'tag:Travel' in the Spotlight search bar.
Finder tags are written to the 'com.apple.metadata:_kMDItemUserTags' extended attribute.
Unlike EXIF metadata, extended attributes do not modify the actual file;
the metadata is written to extended attributes associated with the file and the Spotlight metadata database.
Most cloud storage services do not synch extended attributes.
Dropbox does sync them and any changes to a file's extended attributes
will cause Dropbox to re-sync the files.
# get help text
description = MDITEM_ATTRIBUTE_DATA[attr]["description"]
type_ = MDITEM_ATTRIBUTE_DATA[attr]["help_type"]
attr_help = f"{long_name}; {constant}; {description}; {type_}"
The following attributes may be used with '--xattr-template':
# add to list
attr_tuples.append((short_name, attr_help))
"""
)
formatter.write_dl(attr_tuples)
formatter.write("\n")
formatter.write_text(
"For additional information on extended attributes see: https://developer.apple.com/documentation/coreservices/file_metadata/mditem/common_metadata_attribute_keys"
)
formatter.write("\n")
formatter.write(
rich_text("## Templating System", width=formatter.width, markdown=True)
)
formatter.write("\n")
help_text += formatter.getvalue()
help_text += template_help(width=formatter.width)
formatter = click.HelpFormatter(width=HELP_WIDTH)
# build help text from all the attribute names
# passed to click.HelpFormatter.write_dl for formatting
attr_tuples = [
(
rich_text("[bold]Attribute[/bold]", width=formatter.width),
rich_text("[bold]Description[/bold]", width=formatter.width),
)
]
for attr_key in sorted(EXTENDED_ATTRIBUTE_NAMES):
# get short and long name
attr = MDITEM_ATTRIBUTE_SHORT_NAMES[attr_key]
short_name = MDITEM_ATTRIBUTE_DATA[attr]["short_name"]
long_name = MDITEM_ATTRIBUTE_DATA[attr]["name"]
constant = MDITEM_ATTRIBUTE_DATA[attr]["xattr_constant"]
# get help text
description = MDITEM_ATTRIBUTE_DATA[attr]["description"]
type_ = MDITEM_ATTRIBUTE_DATA[attr]["help_type"]
attr_help = f"{long_name}; {constant}; {description}; {type_}"
# add to list
attr_tuples.append((short_name, attr_help))
formatter.write_dl(attr_tuples)
formatter.write("\n")
formatter.write_text(
"For additional information on extended attributes see: https://developer.apple.com/documentation/coreservices/file_metadata/mditem/common_metadata_attribute_keys"
)
formatter.write("\n")
formatter.write(
rich_text("## Templating System", width=formatter.width, markdown=True)
)
formatter.write("\n")
help_text += formatter.getvalue()
help_text += template_help(width=formatter.width)
formatter = click.HelpFormatter(width=HELP_WIDTH)
formatter.write("\n")
formatter.write("\n")
formatter.write_text(
"With the --directory and --filename options you may specify a template for the "
+ "export directory or filename, respectively. "

View File

@ -20,7 +20,6 @@ from textwrap import dedent
from typing import Callable, Dict, List, Optional, Tuple, Union
import click
from photoscript import Photo, PhotosLibrary
from rich.console import Console
from rich.markdown import Markdown
from strpdatetime import strpdatetime
@ -43,7 +42,11 @@ from osxphotos.photoinfo import PhotoInfoNone
from osxphotos.photosalbum import PhotosAlbumPhotoScript
from osxphotos.phototemplate import PhotoTemplate, RenderOptions
from osxphotos.sqlitekvstore import SQLiteKVStore
from osxphotos.utils import pluralize
from osxphotos.utils import assert_macos, pluralize
assert_macos()
from photoscript import Photo, PhotosLibrary
from .cli_params import THEME_OPTION
from .click_rich_echo import rich_click_echo, rich_echo_error

View File

@ -13,8 +13,6 @@ from typing import Generator, List, Optional, Tuple
import bitmath
import click
from applescript import ScriptError
from photoscript import PhotosLibrary
from rich.console import Console
from rich.layout import Layout
from rich.live import Live
@ -23,8 +21,14 @@ from rich.panel import Panel
from osxphotos import PhotoInfo, PhotosDB
from osxphotos._constants import _UNKNOWN_PERSON, search_category_factory
from osxphotos.rich_utils import add_rich_markup_tag
from osxphotos.utils import assert_macos, dd_to_dms_str
assert_macos()
from applescript import ScriptError
from photoscript import PhotosLibrary
from osxphotos.text_detection import detect_text as detect_text_in_photo
from osxphotos.utils import dd_to_dms_str
from .cli_params import DB_OPTION, THEME_OPTION
from .color_themes import get_theme

View File

@ -1,5 +1,6 @@
"""query command for osxphotos CLI"""
import sys
import click
import osxphotos
@ -9,9 +10,12 @@ from osxphotos.cli.click_rich_echo import (
set_rich_theme,
)
from osxphotos.debug import set_debug
from osxphotos.photosalbum import PhotosAlbum
from osxphotos.phototemplate import RenderOptions
from osxphotos.queryoptions import query_options_from_kwargs
from osxphotos.utils import assert_macos, is_macos
if is_macos:
from osxphotos.photosalbum import PhotosAlbum
from .cli_params import (
DB_ARGUMENT,
@ -20,6 +24,7 @@ from .cli_params import (
FIELD_OPTION,
JSON_OPTION,
QUERY_OPTIONS,
make_click_option_decorator,
)
from .color_themes import get_default_theme
from .common import CLI_COLOR_ERROR, CLI_COLOR_WARNING, OSXPHOTOS_HIDDEN, get_photos_db
@ -27,20 +32,23 @@ from .list import _list_libraries
from .print_photo_info import print_photo_fields, print_photo_info
from .verbose import get_verbose_console
MACOS_OPTIONS = make_click_option_decorator(*[
click.Option(
["--add-to-album"],
metavar="ALBUM",
help="Add all photos from query to album ALBUM in Photos. Album ALBUM will be created "
"if it doesn't exist. All photos in the query results will be added to this album. "
"This only works if the Photos library being queried is the last-opened (default) library in Photos. "
"This feature is currently experimental. I don't know how well it will work on large query sets.",
),
] if is_macos else [])
@click.command()
@DB_OPTION
@JSON_OPTION
@QUERY_OPTIONS
@DELETED_OPTIONS
@click.option(
"--add-to-album",
metavar="ALBUM",
help="Add all photos from query to album ALBUM in Photos. Album ALBUM will be created "
"if it doesn't exist. All photos in the query results will be added to this album. "
"This only works if the Photos library being queried is the last-opened (default) library in Photos. "
"This feature is currently experimental. I don't know how well it will work on large query sets.",
)
@MACOS_OPTIONS
@click.option(
"--quiet",
is_flag=True,
@ -70,8 +78,8 @@ def query(
json_,
print_template,
quiet,
add_to_album,
photos_library,
add_to_album=False,
**kwargs,
):
"""Query the Photos database using 1 or more search options;
@ -124,6 +132,8 @@ def query(
cli_json = cli_obj.json if cli_obj is not None else None
if add_to_album and photos:
assert_macos()
album_query = PhotosAlbum(add_to_album, verbose=None)
photo_len = len(photos)
photo_word = "photos" if photo_len > 1 else "photo"

View File

@ -10,8 +10,6 @@ import time
from typing import List
import click
import photoscript
from applescript import ScriptError
from rich import pretty, print
import osxphotos
@ -25,6 +23,11 @@ from osxphotos.queryoptions import (
QueryOptions,
query_options_from_kwargs,
)
from osxphotos.utils import assert_macos, is_macos
if is_macos:
import photoscript
from applescript import ScriptError
from .cli_params import DB_ARGUMENT, DB_OPTION, DELETED_OPTIONS, QUERY_OPTIONS
from .common import get_photos_db
@ -55,7 +58,8 @@ def repl(ctx, cli_obj, db, emacs, beta, **kwargs):
import logging
from objexplore import explore
from photoscript import Album, Photo, PhotosLibrary
if is_macos:
from photoscript import Album, Photo, PhotosLibrary
from rich import inspect as _inspect
from osxphotos import ExifTool, PhotoInfo, PhotosDB
@ -194,6 +198,7 @@ def _get_selected(photosdb):
"""get list of PhotoInfo objects for photos selected in Photos"""
def get_selected():
assert_macos()
try:
selected = photoscript.PhotosLibrary().selection
except ScriptError as e:
@ -209,6 +214,7 @@ def _get_selected(photosdb):
def _spotlight_photo(photo: PhotoInfo):
assert_macos()
photo_ = photoscript.Photo(photo.uuid)
photo_.spotlight()

View File

@ -7,12 +7,15 @@ import click
from osxphotos._constants import UUID_PATTERN
from osxphotos.export_db_utils import get_uuid_for_filepath
from osxphotos.photosdb.photosdb_utils import get_photos_library_version
from osxphotos.utils import get_last_library_path, assert_macos
assert_macos()
from osxphotos.photoscript_utils import (
photoscript_object_from_name,
photoscript_object_from_uuid,
)
from osxphotos.photosdb.photosdb_utils import get_photos_library_version
from osxphotos.utils import get_last_library_path
from .cli_commands import echo, echo_error
from .cli_params import DB_OPTION

View File

@ -9,7 +9,6 @@ import pathlib
from typing import Any, Callable, Literal
import click
import photoscript
from osxphotos import PhotoInfo, PhotosDB, __version__
from osxphotos.photoinfo import PhotoInfoNone
@ -22,7 +21,11 @@ from osxphotos.queryoptions import (
query_options_from_kwargs,
)
from osxphotos.sqlitekvstore import SQLiteKVStore
from osxphotos.utils import pluralize
from osxphotos.utils import assert_macos, pluralize
assert_macos()
import photoscript
from .cli_params import (
DB_OPTION,

View File

@ -7,7 +7,6 @@ from functools import partial
from textwrap import dedent
import click
from photoscript import PhotosLibrary
from rich.console import Console
from osxphotos._constants import APP_NAME
@ -25,9 +24,13 @@ from osxphotos.photodates import (
update_photo_from_function,
update_photo_time_for_new_timezone,
)
from osxphotos.photosalbum import PhotosAlbumPhotoScript
from osxphotos.phototz import PhotoTimeZone, PhotoTimeZoneUpdater
from osxphotos.utils import noop, pluralize
from osxphotos.utils import assert_macos, noop, pluralize
assert_macos()
from photoscript import PhotosLibrary
from osxphotos.photosalbum import PhotosAlbumPhotoScript
from .cli_params import THEME_OPTION, TIMESTAMP_OPTION, VERBOSE_OPTION
from .click_rich_echo import rich_click_echo as echo

View File

@ -1,6 +1,11 @@
"""uuid command for osxphotos CLI"""
import click
from osxphotos.utils import assert_macos
assert_macos()
import photoscript

View File

@ -5,12 +5,15 @@ from typing import Callable, List, Optional, Tuple
from osxphotos import PhotosDB
from osxphotos.exiftool import ExifTool
from photoscript import Photo
from .datetime_utils import datetime_naive_to_local, datetime_to_new_tz
from .utils import assert_macos, noop
assert_macos()
from photoscript import Photo
from .exif_datetime_updater import get_exif_date_time_offset
from .phototz import PhotoTimeZone
from .utils import noop
ExifDiff = namedtuple(
"ExifDiff",

View File

@ -9,9 +9,11 @@ import typing as t
from abc import ABC, abstractmethod
from tempfile import TemporaryDirectory
import Foundation
from .imageconverter import ImageConverter
from .utils import is_macos, normalize_fs_path
if is_macos:
import Foundation
__all__ = ["FileUtilABC", "FileUtilMacOS", "FileUtilShUtil", "FileUtil", "FileUtilNoOp"]
@ -90,6 +92,9 @@ class FileUtilMacOS(FileUtilABC):
if src is None or dest is None:
raise ValueError("src and dest must not be None", src, dest)
src = normalize_fs_path(src)
dest = normalize_fs_path(dest)
if not os.path.isfile(src):
raise FileNotFoundError("src file does not appear to exist", src)
@ -115,6 +120,9 @@ class FileUtilMacOS(FileUtilABC):
OSError if copy fails
TypeError if either path is None
"""
src = normalize_fs_path(src)
dest = normalize_fs_path(dest)
if not isinstance(src, pathlib.Path):
src = pathlib.Path(src)
@ -135,6 +143,7 @@ class FileUtilMacOS(FileUtilABC):
@classmethod
def unlink(cls, filepath):
"""unlink filepath; if it's pathlib.Path, use Path.unlink, otherwise use os.unlink"""
filepath = normalize_fs_path(filepath)
if isinstance(filepath, pathlib.Path):
filepath.unlink()
else:
@ -143,6 +152,7 @@ class FileUtilMacOS(FileUtilABC):
@classmethod
def rmdir(cls, dirpath):
"""remove directory filepath; dirpath must be empty"""
dirpath = normalize_fs_path(dirpath)
if isinstance(dirpath, pathlib.Path):
dirpath.rmdir()
else:
@ -151,6 +161,7 @@ class FileUtilMacOS(FileUtilABC):
@classmethod
def utime(cls, path, times):
"""Set the access and modified time of path."""
path = normalize_fs_path(path)
os.utime(path, times=times)
@classmethod
@ -166,6 +177,9 @@ class FileUtilMacOS(FileUtilABC):
Does not do a byte-by-byte comparison.
"""
f1 = normalize_fs_path(f1)
f2 = normalize_fs_path(f2)
s1 = cls._sig(os.stat(f1))
if mtime1 is not None:
s1 = (s1[0], s1[1], int(mtime1))
@ -188,6 +202,7 @@ class FileUtilMacOS(FileUtilABC):
if not s2:
return False
f1 = normalize_fs_path(f1)
s1 = cls._sig(os.stat(f1))
if s1[0] != stat.S_IFREG or s2[0] != stat.S_IFREG:
return False
@ -196,6 +211,7 @@ class FileUtilMacOS(FileUtilABC):
@classmethod
def file_sig(cls, f1):
"""return os.stat signature for file f1 as tuple of (mode, size, mtime)"""
f1 = normalize_fs_path(f1)
return cls._sig(os.stat(f1))
@classmethod
@ -210,6 +226,8 @@ class FileUtilMacOS(FileUtilABC):
Returns:
True if success, otherwise False
"""
src_file = normalize_fs_path(src_file)
dest_file = normalize_fs_path(dest_file)
converter = ImageConverter()
return converter.write_jpeg(
src_file, dest_file, compression_quality=compression_quality
@ -227,6 +245,8 @@ class FileUtilMacOS(FileUtilABC):
Name of renamed file (dest)
"""
src = normalize_fs_path(src)
dest = normalize_fs_path(dest)
os.rename(str(src), str(dest))
return dest
@ -271,6 +291,9 @@ class FileUtilShUtil(FileUtilMacOS):
OSError if copy fails
TypeError if either path is None
"""
src = normalize_fs_path(src)
dest = normalize_fs_path(dest)
if not isinstance(src, pathlib.Path):
src = pathlib.Path(src)
@ -288,7 +311,7 @@ class FileUtilShUtil(FileUtilMacOS):
return True
class FileUtil(FileUtilMacOS):
class FileUtil(FileUtilShUtil):
"""Various file utilities"""
pass

View File

@ -5,16 +5,20 @@
# reference: https://stackoverflow.com/questions/59330149/coreimage-ciimage-write-jpg-is-shifting-colors-macos/59334308#59334308
import pathlib
import objc
import Metal
import Quartz
from Cocoa import NSURL
from Foundation import NSDictionary
import sys
# needed to capture system-level stderr
from wurlitzer import pipes
from .utils import is_macos
if is_macos:
import objc
import Metal
import Quartz
from Cocoa import NSURL
from Foundation import NSDictionary
__all__ = ["ImageConversionError", "ImageConverter"]

View File

@ -1,7 +1,20 @@
""" utility functions for validating/sanitizing path components """
""" utility functions for validating/sanitizing path components
This module also performs Unicode normalization. For a quick summary, there are
multiple ways to write more complex characters in Unicode. This causes problems
when e.g. checking if a file already exists and you have multiple sources for
the same string with different encodings. This sadly happens in Photos, but
isn't a problem on macOS, since macOS does normalization behind-the-scenes (see
https://eclecticlight.co/2021/05/08/explainer-unicode-normalization-and-apfs/).
This causes problems on other platforms, so we normalize as part of filename
sanitization functions and rely on them being called every time a unique
filename is needed.
"""
import pathvalidate
from osxphotos.utils import normalize_unicode
from ._constants import MAX_DIRNAME_LEN, MAX_FILENAME_LEN
__all__ = [
@ -24,7 +37,7 @@ def is_valid_filepath(filepath):
def sanitize_filename(filename, replacement=":"):
"""replace any illegal characters in a filename and truncate filename if needed
"""replace any illegal characters in a filename, truncate filename if needed and normalize Unicode to NFC form
Args:
filename: str, filename to sanitze
@ -35,6 +48,7 @@ def sanitize_filename(filename, replacement=":"):
"""
if filename:
filename = normalize_unicode(filename)
filename = filename.replace("/", replacement)
if len(filename) > MAX_FILENAME_LEN:
parts = filename.split(".")
@ -54,7 +68,7 @@ def sanitize_filename(filename, replacement=":"):
def sanitize_dirname(dirname, replacement=":"):
"""replace any illegal characters in a directory name and truncate directory name if needed
"""replace any illegal characters in a directory name, truncate directory name if needed, and normalize Unicode to NFC form
Args:
dirname: str, directory name to sanitize
@ -69,7 +83,7 @@ def sanitize_dirname(dirname, replacement=":"):
def sanitize_pathpart(pathpart, replacement=":"):
"""replace any illegal characters in a path part (either directory or filename without extension) and truncate name if needed
"""replace any illegal characters in a path part (either directory or filename without extension), truncate name if needed, and normalize Unicode to NFC form
Args:
pathpart: str, path part to sanitize
@ -82,6 +96,7 @@ def sanitize_pathpart(pathpart, replacement=":"):
pathpart = (
pathpart.replace("/", replacement) if replacement is not None else pathpart
)
pathpart = normalize_unicode(pathpart)
if len(pathpart) > MAX_DIRNAME_LEN:
drop = len(pathpart) - MAX_DIRNAME_LEN
pathpart = pathpart[:-drop]

View File

@ -7,6 +7,7 @@ import logging
import os
import pathlib
import re
import sys
import typing as t
from collections import namedtuple # pylint: disable=syntax-error
from dataclasses import asdict, dataclass
@ -14,7 +15,6 @@ from datetime import datetime
from enum import Enum
from types import SimpleNamespace
import photoscript
from mako.template import Template
from ._constants import (
@ -35,26 +35,33 @@ from .datetime_utils import datetime_tz_to_utc
from .exiftool import ExifTool, ExifToolCaching, exiftool_can_write, get_exiftool_path
from .export_db import ExportDB, ExportDBTemp
from .fileutil import FileUtil
from .photokit import (
PHOTOS_VERSION_CURRENT,
PHOTOS_VERSION_ORIGINAL,
PHOTOS_VERSION_UNADJUSTED,
PhotoKitFetchFailed,
PhotoLibrary,
)
from .phototemplate import RenderOptions
from .rich_utils import add_rich_markup_tag
from .uti import get_preferred_uti_extension
from .utils import (
is_macos,
hexdigest,
increment_filename,
increment_filename_with_count,
lineno,
list_directory,
lock_filename,
normalize_fs_path,
unlock_filename,
)
if is_macos:
import photoscript
from .photokit import (
PHOTOS_VERSION_CURRENT,
PHOTOS_VERSION_ORIGINAL,
PHOTOS_VERSION_UNADJUSTED,
PhotoKitFetchFailed,
PhotoLibrary,
)
__all__ = [
"ExportError",
"ExportOptions",
@ -721,7 +728,6 @@ class PhotoExporter:
self, src: pathlib.Path, dest: pathlib.Path, options: ExportOptions
) -> t.Literal[True, False]:
"""Return True if photo should be updated, else False"""
# NOTE: The order of certain checks is important
# read the comments below to understand why before changing
@ -1181,7 +1187,7 @@ class PhotoExporter:
try:
fileutil.copy(src, dest_str)
verbose(
f"Exported {self._filename(self.photo.original_filename)} to {self._filepath(dest_str)}"
f"Exported {self._filename(self.photo.original_filename)} to {self._filepath(normalize_fs_path(dest_str))}"
)
except Exception as e:
raise ExportError(

View File

@ -20,7 +20,6 @@ from types import SimpleNamespace
from typing import Any, Dict, Optional
import yaml
from osxmetadata import OSXMetaData
import osxphotos
@ -65,9 +64,12 @@ from .placeinfo import PlaceInfo4, PlaceInfo5
from .query_builder import get_query
from .scoreinfo import ScoreInfo
from .searchinfo import SearchInfo
from .text_detection import detect_text
from .uti import get_preferred_uti_extension, get_uti_for_extension
from .utils import _get_resource_loc, hexdigest, list_directory
from .utils import _get_resource_loc, assert_macos, is_macos, hexdigest, list_directory
if is_macos:
from osxmetadata import OSXMetaData
from .text_detection import detect_text
__all__ = ["PhotoInfo", "PhotoInfoNone", "frozen_photoinfo_factory"]
@ -1461,6 +1463,8 @@ class PhotoInfo:
def _detected_text(self):
"""detect text in photo, either from cached extended attribute or by attempting text detection"""
assert_macos()
path = (
self.path_edited if self.hasadjustments and self.path_edited else self.path
)

View File

@ -19,9 +19,12 @@
import copy
import pathlib
import sys
import threading
import time
assert(sys.platform == "darwin")
import AVFoundation
import CoreServices
import Foundation

View File

@ -2,12 +2,15 @@
from typing import List, Optional
import photoscript
from more_itertools import chunked
from photoscript import Album, Folder, Photo, PhotosLibrary
from .photoinfo import PhotoInfo
from .utils import noop, pluralize
from .utils import assert_macos, noop, pluralize
assert_macos()
import photoscript
from photoscript import Album, Folder, Photo, PhotosLibrary
__all__ = ["PhotosAlbum", "PhotosAlbumPhotoScript"]

View File

@ -4,6 +4,10 @@ from __future__ import annotations
import sqlite3
from .utils import assert_macos
assert_macos()
import photoscript
from ._constants import _DB_TABLE_NAMES, _PHOTOS_5_ALBUM_KIND, _PHOTOS_5_FOLDER_KIND

View File

@ -21,7 +21,6 @@ from typing import Any, List, Optional
from unicodedata import normalize
import bitmath
import photoscript
from rich import print
from .._constants import (
@ -62,6 +61,7 @@ from ..rich_utils import add_rich_markup_tag
from ..sqlite_utils import sqlite_db_is_locked, sqlite_open_ro
from ..utils import (
_check_file_exists,
is_macos,
get_macos_version,
get_last_library_path,
noop,
@ -69,6 +69,9 @@ from ..utils import (
)
from .photosdb_utils import get_db_model_version, get_db_version
if is_macos:
import photoscript
logger = logging.getLogger("osxphotos")
__all__ = ["PhotosDB"]
@ -118,8 +121,8 @@ class PhotosDB:
# Check OS version
system = platform.system()
(ver, major, _) = get_macos_version()
if system != "Darwin" or ((ver, major) not in _TESTED_OS_VERSIONS):
(ver, major, _) = get_macos_version() if is_macos else (None, None, None)
if system == "Darwin" and ((ver, major) not in _TESTED_OS_VERSIONS):
logging.warning(
f"WARNING: This module has only been tested with macOS versions "
f"[{', '.join(f'{v}.{m}' for (v, m) in _TESTED_OS_VERSIONS)}]: "

View File

@ -21,7 +21,6 @@ from ._version import __version__
from .datetime_formatter import DateTimeFormatter
from .exiftool import ExifToolCaching
from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart
from .text_detection import detect_text
from .utils import expand_and_validate_filepath, load_function, uuid_to_shortuuid
__all__ = [

View File

@ -1,8 +1,13 @@
""" Use Apple's Vision Framework via PyObjC to perform text detection on images (macOS 10.15+ only) """
import logging
import sys
from typing import List, Optional
from .utils import assert_macos, get_macos_version
assert_macos()
import objc
import Quartz
from Cocoa import NSURL
@ -11,8 +16,6 @@ from Foundation import NSDictionary
# needed to capture system-level stderr
from wurlitzer import pipes
from .utils import get_macos_version
__all__ = ["detect_text", "make_request_handler"]
ver, major, minor = get_macos_version()

View File

@ -2,13 +2,7 @@
from typing import Union
import Foundation
import objc
def known_timezone_names():
"""Get list of valid timezones on macOS"""
return Foundation.NSTimeZone.knownTimeZoneNames()
from .utils import is_macos
def format_offset_time(offset: int) -> str:
@ -19,43 +13,107 @@ def format_offset_time(offset: int) -> str:
return f"{sign}{hours:02d}:{minutes:02d}"
class Timezone:
"""Create Timezone object from either name (str) or offset from GMT (int)"""
if is_macos:
import Foundation
import objc
def __init__(self, tz: Union[str, int]):
with objc.autorelease_pool():
def known_timezone_names():
"""Get list of valid timezones on macOS"""
return Foundation.NSTimeZone.knownTimeZoneNames()
class Timezone:
"""Create Timezone object from either name (str) or offset from GMT (int)"""
def __init__(self, tz: Union[str, int]):
with objc.autorelease_pool():
if isinstance(tz, str):
self.timezone = Foundation.NSTimeZone.timeZoneWithName_(tz)
self._name = tz
elif isinstance(tz, int):
self.timezone = Foundation.NSTimeZone.timeZoneForSecondsFromGMT_(tz)
self._name = self.timezone.name()
else:
raise TypeError("Timezone must be a string or an int")
@property
def name(self) -> str:
return self._name
@property
def offset(self) -> int:
return self.timezone.secondsFromGMT()
@property
def offset_str(self) -> str:
return format_offset_time(self.offset)
@property
def abbreviation(self) -> str:
return self.timezone.abbreviation()
def __str__(self):
return self.name
def __repr__(self):
return self.name
def __eq__(self, other):
if isinstance(other, Timezone):
return self.timezone == other.timezone
return False
else:
import zoneinfo
from datetime import datetime
def known_timezone_names():
"""Get list of valid timezones"""
return zoneinfo.available_timezones()
class Timezone:
"""Create Timezone object from either name (str) or offset from GMT (int)"""
def __init__(self, tz: Union[str, int]):
if isinstance(tz, str):
self.timezone = Foundation.NSTimeZone.timeZoneWithName_(tz)
self.timezone = zoneinfo.ZoneInfo(tz)
self._name = tz
elif isinstance(tz, int):
self.timezone = Foundation.NSTimeZone.timeZoneForSecondsFromGMT_(tz)
self._name = self.timezone.name()
if tz > 0:
name = f"Etc/GMT+{tz // 3600}"
else:
name = f"Etc/GMT-{-tz // 3600}"
self.timezone = zoneinfo.ZoneInfo(name)
self._name = self.timezone.key
else:
raise TypeError("Timezone must be a string or an int")
@property
def name(self) -> str:
return self._name
@property
def name(self) -> str:
return self._name
@property
def offset(self) -> int:
return self.timezone.secondsFromGMT()
@property
def offset(self) -> int:
td = self.timezone.utcoffset(datetime.now())
assert td
return int(td.total_seconds())
@property
def offset_str(self) -> str:
return format_offset_time(self.offset)
@property
def offset_str(self) -> str:
return format_offset_time(self.offset)
@property
def abbreviation(self) -> str:
return self.timezone.abbreviation()
@property
def abbreviation(self) -> str:
return self.timezone.key
def __str__(self):
return self.name
def __str__(self):
return self.name
def __repr__(self):
return self.name
def __repr__(self):
return self.name
def __eq__(self, other):
if isinstance(other, Timezone):
return self.timezone == other.timezone
return False
def __eq__(self, other):
if isinstance(other, Timezone):
return self.timezone == other.timezone
return False

View File

@ -1,7 +1,5 @@
""" get UTI for a given file extension and the preferred extension for a given UTI
Implementation note: runs only on macOS
On macOS <= 11 (Big Sur), uses objective C CoreServices methods
UTTypeCopyPreferredTagWithClass and UTTypeCreatePreferredIdentifierForTag to retrieve the
UTI and the extension. These are deprecated in 10.15 (Catalina) and no longer supported on Monterey.
@ -13,6 +11,8 @@ Implementation note: runs only on macOS
works for the extension -> UTI lookup. On Monterey, if there is no cached value for UTI -> extension lookup,
returns None.
Outside of macOS uses only the hardcoded list of UTIs.
It's a bit hacky but best I can think of to make this robust on different versions of macOS. PRs welcome.
"""
@ -21,12 +21,14 @@ from __future__ import annotations
import csv
import re
import subprocess
import sys
import tempfile
import CoreServices
import objc
from .utils import assert_macos, is_macos, get_macos_version
from .utils import get_macos_version
if is_macos:
import CoreServices
import objc
__all__ = ["get_preferred_uti_extension", "get_uti_for_extension"]
@ -518,11 +520,10 @@ def _load_uti_dict():
EXT_UTI_DICT[row["extension"]] = row["UTI"]
UTI_EXT_DICT[row["UTI"]] = row["preferred_extension"]
_load_uti_dict()
# OS version for determining which methods can be used
OS_VER, OS_MAJOR, _ = (int(x) for x in get_macos_version())
OS_VER, OS_MAJOR, _ = (int(x) for x in get_macos_version()) if is_macos else (None, None, None)
def _get_uti_from_mdls(extension):
@ -532,6 +533,9 @@ def _get_uti_from_mdls(extension):
# mdls -name kMDItemContentType foo.3fr
# kMDItemContentType = "com.hasselblad.3fr-raw-image"
if not is_macos:
return None
try:
with tempfile.NamedTemporaryFile(suffix="." + extension) as temp:
output = subprocess.check_output(
@ -573,7 +577,7 @@ def get_preferred_uti_extension(uti: str) -> str | None:
uti: UTI str, e.g. 'public.jpeg'
returns: preferred extension as str or None if cannot be determined"""
if (OS_VER, OS_MAJOR) <= (10, 16):
if is_macos and (OS_VER, OS_MAJOR) <= (10, 16):
# reference: https://developer.apple.com/documentation/coreservices/1442744-uttypecopypreferredtagwithclass?language=objc
# deprecated in Catalina+, likely won't work at all on macOS 12
with objc.autorelease_pool():
@ -602,7 +606,7 @@ def get_uti_for_extension(extension):
if extension[0] == ".":
extension = extension[1:]
if (OS_VER, OS_MAJOR) <= (10, 16):
if is_macos and (OS_VER, OS_MAJOR) <= (10, 16):
# https://developer.apple.com/documentation/coreservices/1448939-uttypecreatepreferredidentifierf
with objc.autorelease_pool():
uti = CoreServices.UTTypeCreatePreferredIdentifierForTag(

View File

@ -16,10 +16,9 @@ import sys
import unicodedata
import urllib.parse
from plistlib import load as plistload
from typing import Callable, List, Optional, Tuple, Union
from typing import Any, Callable, List, Optional, Tuple, TypeVar, Union
from uuid import UUID
import CoreFoundation
import requests
import shortuuid
@ -28,6 +27,8 @@ from ._constants import UNICODE_FORMAT
logger = logging.getLogger("osxphotos")
__all__ = [
"is_macos",
"assert_macos",
"dd_to_dms_str",
"expand_and_validate_filepath",
"get_last_library_path",
@ -53,6 +54,16 @@ __all__ = [
VERSION_INFO_URL = "https://pypi.org/pypi/osxphotos/json"
is_macos = sys.platform == "darwin"
def assert_macos():
assert is_macos, "This feature only runs on macOS"
if is_macos:
import CoreFoundation
def noop(*args, **kwargs):
"""do nothing (no operation)"""
pass
@ -67,6 +78,7 @@ def lineno(filename):
def get_macos_version():
assert_macos()
# returns tuple of str containing OS version
# e.g. 10.13.6 = ("10", "13", "6")
version = platform.mac_ver()[0].split(".")
@ -166,6 +178,8 @@ def get_system_library_path():
"""return the path to the system Photos library as string"""
""" only works on MacOS 10.15 """
""" on earlier versions, returns None """
if not is_macos:
return None
_, major, _ = get_macos_version()
if int(major) < 15:
logger.debug(
@ -241,6 +255,8 @@ def get_last_library_path():
def list_photo_libraries():
"""returns list of Photos libraries found on the system"""
""" on MacOS < 10.15, this may omit some libraries """
if not is_macos:
return []
# On 10.15, mdfind appears to find all libraries
# On older MacOS versions, mdfind appears to ignore some libraries
@ -263,11 +279,14 @@ def list_photo_libraries():
return lib_list
def normalize_fs_path(path: str) -> str:
T = TypeVar("T", bound=Union[str, pathlib.Path])
def normalize_fs_path(path: T) -> T:
"""Normalize filesystem paths with unicode in them"""
# macOS HFS+ uses NFD, APFS doesn't normalize but stick with NFD
# ref: https://eclecticlight.co/2021/05/08/explainer-unicode-normalization-and-apfs/
return unicodedata.normalize("NFD", path)
form = "NFD" if is_macos else "NFC"
if isinstance(path, pathlib.Path):
return pathlib.Path(unicodedata.normalize(form, str(path)))
else:
return unicodedata.normalize(form, path)
# def findfiles(pattern, path):
@ -356,7 +375,7 @@ def list_directory(
return files
def normalize_unicode(value):
def normalize_unicode(value) -> Any:
"""normalize unicode data"""
if value is None:
return None

View File

@ -5,14 +5,19 @@ import shutil
import tempfile
import time
import photoscript
import pytest
from applescript import AppleScript
from photoscript.utils import ditto
from osxphotos.utils import is_macos
if is_macos:
import photoscript
from applescript import AppleScript
from photoscript.utils import ditto
from .test_catalina_10_15_7 import UUID_DICT_LOCAL
from osxphotos.exiftool import _ExifToolProc
from .test_catalina_10_15_7 import UUID_DICT_LOCAL
# run timewarp tests (configured with --timewarp)
TEST_TIMEWARP = False
@ -34,6 +39,9 @@ NO_CLEANUP = False
def get_os_version():
if not is_macos:
return (None, None, None)
import platform
# returns tuple containing OS version
@ -53,7 +61,7 @@ def get_os_version():
return (ver, major, minor)
OS_VER = get_os_version()
OS_VER = get_os_version() if is_macos else [None, None]
if OS_VER[0] == "10" and OS_VER[1] == "15":
# Catalina
TEST_LIBRARY = "tests/Test-10.15.7.photoslibrary"
@ -77,28 +85,28 @@ else:
TEST_LIBRARY_ADD_LOCATIONS = None
@pytest.fixture(scope="session", autouse=True)
@pytest.fixture(scope="session", autouse=is_macos)
def setup_photos_timewarp():
if not TEST_TIMEWARP:
return
copy_photos_library(TEST_LIBRARY_TIMEWARP, delay=5)
@pytest.fixture(scope="session", autouse=True)
@pytest.fixture(scope="session", autouse=is_macos)
def setup_photos_import():
if not TEST_IMPORT:
return
copy_photos_library(TEST_LIBRARY_IMPORT, delay=10)
@pytest.fixture(scope="session", autouse=True)
@pytest.fixture(scope="session", autouse=is_macos)
def setup_photos_sync():
if not TEST_SYNC:
return
copy_photos_library(TEST_LIBRARY_SYNC, delay=10)
@pytest.fixture(scope="session", autouse=True)
@pytest.fixture(scope="session", autouse=is_macos)
def setup_photos_add_locations():
if not TEST_ADD_LOCATIONS:
return
@ -312,7 +320,10 @@ def addalbum_library():
def copy_photos_library_to_path(photos_library_path: str, dest_path: str) -> str:
"""Copy a photos library to a folder"""
ditto(photos_library_path, dest_path)
if is_macos:
ditto(photos_library_path, dest_path)
else:
shutil.copytree(photos_library_path, dest_path)
return dest_path

16
tests/locale_util.py Normal file
View File

@ -0,0 +1,16 @@
""" Helpers for running locale-dependent tests """
import contextlib
import locale
import pytest
def setlocale(typ, name):
try:
with contextlib.suppress(Exception):
locale.setlocale(typ, name)
# On Linux UTF-8 locales are separate
locale.setlocale(typ, f"{name}.UTF-8")
except locale.Error:
pytest.skip(f"Locale {name} not available")

View File

@ -15,9 +15,9 @@ import pytest
import osxphotos
from osxphotos._constants import _UNKNOWN_PERSON
from osxphotos.photoexporter import PhotoExporter
from osxphotos.utils import get_macos_version
from osxphotos.utils import is_macos, get_macos_version
OS_VERSION = get_macos_version()
OS_VERSION = get_macos_version() if is_macos else (None, None, None)
SKIP_TEST = "OSXPHOTOS_TEST_EXPORT" not in os.environ or OS_VERSION[1] != "15"
PHOTOS_DB_LOCAL = os.path.expanduser("~/Pictures/Photos Library.photoslibrary")
@ -1448,6 +1448,7 @@ def test_multi_uuid(photosdb):
assert len(photos) == 2
@pytest.mark.skipif(not is_macos, reason="Only works on macOS")
def test_detected_text(photosdb):
"""test PhotoInfo.detected_text"""
for uuid, expected_text in UUID_DETECTED_TEXT.items():

View File

@ -14,10 +14,10 @@ import subprocess
import tempfile
import time
from tempfile import TemporaryDirectory
from bitmath import contextlib
import pytest
from click.testing import CliRunner
from osxmetadata import OSXMetaData, Tag
import osxphotos
from osxphotos._constants import OSXPHOTOS_EXPORT_DB
@ -36,9 +36,16 @@ from osxphotos.cli import (
)
from osxphotos.exiftool import ExifTool, get_exiftool_path
from osxphotos.fileutil import FileUtil
from osxphotos.utils import noop, normalize_fs_path, normalize_unicode
from osxphotos.utils import is_macos, noop, normalize_fs_path, normalize_unicode
if is_macos:
from osxmetadata import OSXMetaData, Tag
from .conftest import copy_photos_library_to_path
from .locale_util import setlocale
def _normalize_fs_paths(paths):
"""Small helper to prepare path strings for test"""
return [normalize_fs_path(p) for p in paths]
CLI_PHOTOS_DB = "tests/Test-10.15.7.photoslibrary"
LIVE_PHOTOS_DB = "tests/Test-Cloud-10.15.1.photoslibrary"
@ -66,7 +73,7 @@ SKIP_UUID_FILE = "tests/skip_uuid_from_file.txt"
CLI_OUTPUT_QUERY_UUID = '[{"uuid": "D79B8D77-BFFC-460B-9312-034F2877D35B", "filename": "D79B8D77-BFFC-460B-9312-034F2877D35B.jpeg", "original_filename": "Pumkins2.jpg", "date": "2018-09-28T16:07:07-04:00", "description": "Girl holding pumpkin", "title": "I found one!", "keywords": ["Kids"], "albums": ["Pumpkin Farm", "Test Album", "Multi Keyword"], "persons": ["Katie"], "path": "/tests/Test-10.15.7.photoslibrary/originals/D/D79B8D77-BFFC-460B-9312-034F2877D35B.jpeg", "ismissing": false, "hasadjustments": false, "external_edit": false, "favorite": false, "hidden": false, "latitude": 41.256566, "longitude": -95.940257, "path_edited": null, "shared": false, "isphoto": true, "ismovie": false, "uti": "public.jpeg", "burst": false, "live_photo": false, "path_live_photo": null, "iscloudasset": false, "incloud": null}]'
CLI_EXPORT_FILENAMES = [
CLI_EXPORT_FILENAMES = _normalize_fs_paths([
"[2020-08-29] AAF035 (1).jpg",
"[2020-08-29] AAF035 (2).jpg",
"[2020-08-29] AAF035 (3).jpg",
@ -100,10 +107,10 @@ CLI_EXPORT_FILENAMES = [
"wedding.jpg",
"winebottle (1).jpeg",
"winebottle.jpeg",
]
])
CLI_EXPORT_FILENAMES_DRY_RUN = [
CLI_EXPORT_FILENAMES_DRY_RUN = _normalize_fs_paths([
"[2020-08-29] AAF035.jpg",
"DSC03584.dng",
"Frítest_edited.jpeg",
@ -130,15 +137,25 @@ CLI_EXPORT_FILENAMES_DRY_RUN = [
"wedding.jpg",
"winebottle.jpeg",
"winebottle.jpeg",
]
])
CLI_EXPORT_IGNORE_SIGNATURE_FILENAMES = ["Tulips.jpg", "wedding.jpg"]
CLI_EXPORT_IGNORE_SIGNATURE_FILENAMES = _normalize_fs_paths([
"Tulips.jpg",
"wedding.jpg"
])
CLI_EXPORT_FILENAMES_ALBUM = ["Pumkins1.jpg", "Pumkins2.jpg", "Pumpkins3.jpg"]
CLI_EXPORT_FILENAMES_ALBUM = _normalize_fs_paths([
"Pumkins1.jpg",
"Pumkins2.jpg",
"Pumpkins3.jpg"
])
CLI_EXPORT_FILENAMES_ALBUM_UNICODE = ["IMG_4547.jpg"]
CLI_EXPORT_FILENAMES_ALBUM_UNICODE = _normalize_fs_paths(["IMG_4547.jpg"])
CLI_EXPORT_FILENAMES_DELETED_TWIN = ["wedding.jpg", "wedding_edited.jpeg"]
CLI_EXPORT_FILENAMES_DELETED_TWIN = _normalize_fs_paths([
"wedding.jpg",
"wedding_edited.jpeg"
])
CLI_EXPORT_EDITED_SUFFIX = "_bearbeiten"
CLI_EXPORT_EDITED_SUFFIX_TEMPLATE = "{edited?_edited,}"
@ -146,7 +163,7 @@ CLI_EXPORT_ORIGINAL_SUFFIX = "_original"
CLI_EXPORT_ORIGINAL_SUFFIX_TEMPLATE = "{edited?_original,}"
CLI_EXPORT_PREVIEW_SUFFIX = "_lowres"
CLI_EXPORT_FILENAMES_EDITED_SUFFIX = [
CLI_EXPORT_FILENAMES_EDITED_SUFFIX = _normalize_fs_paths([
"[2020-08-29] AAF035 (1).jpg",
"[2020-08-29] AAF035 (2).jpg",
"[2020-08-29] AAF035 (3).jpg",
@ -180,9 +197,9 @@ CLI_EXPORT_FILENAMES_EDITED_SUFFIX = [
"wedding.jpg",
"winebottle (1).jpeg",
"winebottle.jpeg",
]
])
CLI_EXPORT_FILENAMES_EDITED_SUFFIX_TEMPLATE = [
CLI_EXPORT_FILENAMES_EDITED_SUFFIX_TEMPLATE = _normalize_fs_paths([
"[2020-08-29] AAF035 (1).jpg",
"[2020-08-29] AAF035 (2).jpg",
"[2020-08-29] AAF035 (3).jpg",
@ -216,9 +233,9 @@ CLI_EXPORT_FILENAMES_EDITED_SUFFIX_TEMPLATE = [
"wedding.jpg",
"winebottle (1).jpeg",
"winebottle.jpeg",
]
])
CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX = [
CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX = _normalize_fs_paths([
"[2020-08-29] AAF035_original (1).jpg",
"[2020-08-29] AAF035_original (2).jpg",
"[2020-08-29] AAF035_original (3).jpg",
@ -252,9 +269,9 @@ CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX = [
"wedding_original.jpg",
"winebottle_original (1).jpeg",
"winebottle_original.jpeg",
]
])
CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX_TEMPLATE = [
CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX_TEMPLATE = _normalize_fs_paths([
"[2020-08-29] AAF035 (1).jpg",
"[2020-08-29] AAF035 (2).jpg",
"[2020-08-29] AAF035 (3).jpg",
@ -288,7 +305,7 @@ CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX_TEMPLATE = [
"wedding_original.jpg",
"winebottle (1).jpeg",
"winebottle.jpeg",
]
])
CLI_EXPORT_FILENAMES_CURRENT = [
"1793FAAB-DE75-4E25-886C-2BD66C780D6A_edited.jpeg", # Frítest.jpg
@ -326,7 +343,7 @@ CLI_EXPORT_FILENAMES_CURRENT = [
"F207D5DE-EFAD-4217-8424-0764AAC971D0.jpeg",
]
CLI_EXPORT_FILENAMES_CONVERT_TO_JPEG = [
CLI_EXPORT_FILENAMES_CONVERT_TO_JPEG = _normalize_fs_paths([
"[2020-08-29] AAF035 (1).jpg",
"[2020-08-29] AAF035 (2).jpg",
"[2020-08-29] AAF035 (3).jpg",
@ -360,9 +377,9 @@ CLI_EXPORT_FILENAMES_CONVERT_TO_JPEG = [
"wedding.jpg",
"winebottle (1).jpeg",
"winebottle.jpeg",
]
])
CLI_EXPORT_FILENAMES_CONVERT_TO_JPEG_SKIP_RAW = [
CLI_EXPORT_FILENAMES_CONVERT_TO_JPEG_SKIP_RAW = _normalize_fs_paths([
"[2020-08-29] AAF035 (1).jpg",
"[2020-08-29] AAF035 (2).jpg",
"[2020-08-29] AAF035 (3).jpg",
@ -394,26 +411,26 @@ CLI_EXPORT_FILENAMES_CONVERT_TO_JPEG_SKIP_RAW = [
"wedding.jpg",
"winebottle (1).jpeg",
"winebottle.jpeg",
]
])
CLI_EXPORT_CONVERT_TO_JPEG_LARGE_FILE = "DSC03584.jpeg"
CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES1 = [
CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES1 = _normalize_fs_paths([
"2019/April/wedding.jpg",
"2019/July/Tulips.jpg",
"2018/October/St James Park.jpg",
"2018/September/Pumpkins3.jpg",
"2018/September/Pumkins2.jpg",
"2018/September/Pumkins1.jpg",
]
])
CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES_LOCALE = [
CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES_LOCALE = _normalize_fs_paths([
"2019/September/IMG_9975.JPEG",
"2020/Februar/IMG_1064.JPEG",
"2016/März/IMG_3984.JPEG",
]
])
CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES_ALBUM1 = [
CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES_ALBUM1 = _normalize_fs_paths([
"Multi Keyword/wedding.jpg",
"_/Tulips.jpg",
"_/St James Park.jpg",
@ -421,9 +438,9 @@ CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES_ALBUM1 = [
"Pumpkin Farm/Pumkins2.jpg",
"Pumpkin Farm/Pumkins1.jpg",
"Test Album/Pumkins1.jpg",
]
])
CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES_ALBUM2 = [
CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES_ALBUM2 = _normalize_fs_paths([
"Multi Keyword/wedding.jpg",
"NOALBUM/Tulips.jpg",
"NOALBUM/St James Park.jpg",
@ -431,28 +448,28 @@ CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES_ALBUM2 = [
"Pumpkin Farm/Pumkins2.jpg",
"Pumpkin Farm/Pumkins1.jpg",
"Test Album/Pumkins1.jpg",
]
])
CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES2 = [
CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES2 = _normalize_fs_paths([
"St James's Park, Great Britain, Westminster, England, United Kingdom/St James Park.jpg",
"_/Pumpkins3.jpg",
"Omaha, Nebraska, United States/Pumkins2.jpg",
"_/Pumkins1.jpg",
"_/Tulips.jpg",
"_/wedding.jpg",
]
])
CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES3 = [
CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES3 = _normalize_fs_paths([
"2019/{foo}/wedding.jpg",
"2019/{foo}/Tulips.jpg",
"2018/{foo}/St James Park.jpg",
"2018/{foo}/Pumpkins3.jpg",
"2018/{foo}/Pumkins2.jpg",
"2018/{foo}/Pumkins1.jpg",
]
])
CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES1 = [
CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES1 = _normalize_fs_paths([
"2019-wedding.jpg",
"2019-wedding_edited.jpeg",
"2019-Tulips.jpg",
@ -461,9 +478,9 @@ CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES1 = [
"2018-Pumpkins3.jpg",
"2018-Pumkins2.jpg",
"2018-Pumkins1.jpg",
]
])
CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES2 = [
CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES2 = _normalize_fs_paths([
"Folder1_SubFolder2_AlbumInFolder-IMG_4547.jpg",
"Folder1_SubFolder2_AlbumInFolder-wedding.jpg",
"Folder1_SubFolder2_AlbumInFolder-wedding_edited.jpeg",
@ -484,18 +501,18 @@ CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES2 = [
"None-IMG_1693.tif",
"I have a deleted twin-wedding.jpg",
"I have a deleted twin-wedding_edited.jpeg",
]
])
CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES_PATHSEP = [
CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES_PATHSEP = _normalize_fs_paths([
"2018-10 - Sponsion, Museum, Frühstück, Römermuseum/IMG_4547.jpg",
"Folder1/SubFolder2/AlbumInFolder/IMG_4547.jpg",
"2019-10:11 Paris Clermont/IMG_4547.jpg",
]
])
CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES_KEYWORD_PATHSEP = [
CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES_KEYWORD_PATHSEP = _normalize_fs_paths([
"foo:bar/foo:bar_IMG_3092.heic"
]
])
CLI_EXPORTED_FILENAME_TEMPLATE_LONG_DESCRIPTION = [
"Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo"
@ -519,46 +536,59 @@ CLI_EXPORT_BY_DATE_TOUCH_UUID = [
"F12384F6-CD17-4151-ACBA-AE0E3688539E", # Pumkins1.jpg
]
CLI_EXPORT_BY_DATE_TOUCH_TIMES = [1538165373, 1538163349]
CLI_EXPORT_BY_DATE_NEED_TOUCH = [
CLI_EXPORT_BY_DATE_NEED_TOUCH = _normalize_fs_paths([
"2018/09/28/Pumkins2.jpg",
"2018/10/13/St James Park.jpg",
]
])
CLI_EXPORT_BY_DATE_NEED_TOUCH_UUID = [
"D79B8D77-BFFC-460B-9312-034F2877D35B",
"DC99FBDD-7A52-4100-A5BB-344131646C30",
]
CLI_EXPORT_BY_DATE_NEED_TOUCH_TIMES = [1538165227, 1539436692]
CLI_EXPORT_BY_DATE = ["2018/09/28/Pumpkins3.jpg", "2018/09/28/Pumkins1.jpg"]
CLI_EXPORT_BY_DATE = _normalize_fs_paths([
"2018/09/28/Pumpkins3.jpg",
"2018/09/28/Pumkins1.jpg",
])
CLI_EXPORT_SIDECAR_FILENAMES = ["Pumkins2.jpg", "Pumkins2.jpg.json", "Pumkins2.jpg.xmp"]
CLI_EXPORT_SIDECAR_DROP_EXT_FILENAMES = [
CLI_EXPORT_SIDECAR_FILENAMES = _normalize_fs_paths([
"Pumkins2.jpg",
"Pumkins2.jpg.json",
"Pumkins2.jpg.xmp",
])
CLI_EXPORT_SIDECAR_DROP_EXT_FILENAMES = _normalize_fs_paths([
"Pumkins2.jpg",
"Pumkins2.json",
"Pumkins2.xmp",
]
])
CLI_EXPORT_LIVE = [
"51F2BEF7-431A-4D31-8AC1-3284A57826AE.jpeg",
"51F2BEF7-431A-4D31-8AC1-3284A57826AE.mov",
]
CLI_EXPORT_LIVE_ORIGINAL = ["IMG_0728.JPG", "IMG_0728.mov"]
CLI_EXPORT_LIVE_ORIGINAL = _normalize_fs_paths([
"IMG_0728.JPG",
"IMG_0728.mov",
])
CLI_EXPORT_RAW = ["441DFE2A-A69B-4C79-A69B-3F51D1B9B29C.cr2"]
CLI_EXPORT_RAW_ORIGINAL = ["IMG_0476_2.CR2"]
CLI_EXPORT_RAW_ORIGINAL = _normalize_fs_paths(["IMG_0476_2.CR2"])
CLI_EXPORT_RAW_EDITED = [
"441DFE2A-A69B-4C79-A69B-3F51D1B9B29C.cr2",
"441DFE2A-A69B-4C79-A69B-3F51D1B9B29C_edited.jpeg",
]
CLI_EXPORT_RAW_EDITED_ORIGINAL = ["IMG_0476_2.CR2", "IMG_0476_2_edited.jpeg"]
CLI_EXPORT_RAW_EDITED_ORIGINAL = _normalize_fs_paths([
"IMG_0476_2.CR2",
"IMG_0476_2_edited.jpeg",
])
CLI_UUID_DICT_15_7 = {
"intrash": "71E3E212-00EB-430D-8A63-5E294B268554",
"template": "F12384F6-CD17-4151-ACBA-AE0E3688539E",
}
CLI_TEMPLATE_SIDECAR_FILENAME = "Pumkins1.jpg.json"
CLI_TEMPLATE_FILENAME = "Pumkins1.jpg"
CLI_TEMPLATE_SIDECAR_FILENAME = normalize_fs_path("Pumkins1.jpg.json")
CLI_TEMPLATE_FILENAME = normalize_fs_path("Pumkins1.jpg")
CLI_UUID_DICT_14_6 = {"intrash": "3tljdX43R8+k6peNHVrJNQ"}
@ -960,12 +990,12 @@ UUID_UNICODE_TITLE = [
"D1D4040D-D141-44E8-93EA-E403D9F63E07", # Frítest.jpg
]
EXPORT_UNICODE_TITLE_FILENAMES = [
EXPORT_UNICODE_TITLE_FILENAMES = _normalize_fs_paths([
"Frítest.jpg",
"Frítest (1).jpg",
"Frítest (2).jpg",
"Frítest (3).jpg",
]
])
# data for --report
UUID_REPORT = [
@ -987,12 +1017,12 @@ QUERY_EXIF_DATA_CASE_INSENSITIVE = [
EXPORT_EXIF_DATA = [("EXIF:Make", "FUJIFILM", ["Tulips.jpg", "Tulips_edited.jpeg"])]
UUID_LIVE_EDITED = "136A78FA-1B90-46CC-88A7-CCA3331F0353" # IMG_4813.HEIC
CLI_EXPORT_LIVE_EDITED = [
CLI_EXPORT_LIVE_EDITED = _normalize_fs_paths([
"IMG_4813.HEIC",
"IMG_4813.mov",
"IMG_4813_edited.jpeg",
"IMG_4813_edited.mov",
]
])
UUID_FAVORITE = "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51"
FILE_FAVORITE = "wedding.jpg"
@ -1804,12 +1834,25 @@ def test_export_preview_update():
assert len(files) == 2 # preview + original
@contextlib.contextmanager
def isolated_filesystem_here():
cwd = os.getcwd()
tempdir = tempfile.mkdtemp(dir=cwd) # type: ignore[type-var]
os.chdir(tempdir)
try:
yield tempdir
finally:
os.chdir(cwd)
shutil.rmtree(tempdir)
def test_export_as_hardlink():
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
with isolated_filesystem_here():
result = runner.invoke(
export,
[os.path.join(cwd, CLI_PHOTOS_DB), ".", "--export-as-hardlink", "-V"],
@ -1829,7 +1872,7 @@ def test_export_as_hardlink_samefile():
photo = photosdb.photos(uuid=[CLI_EXPORT_UUID])[0]
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
with isolated_filesystem_here():
result = runner.invoke(
export,
[
@ -1854,7 +1897,7 @@ def test_export_using_hardlinks_incompat_options():
photo = photosdb.photos(uuid=[CLI_EXPORT_UUID])[0]
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
with isolated_filesystem_here():
result = runner.invoke(
export,
[
@ -3717,7 +3760,7 @@ def test_export_raw_edited_original():
def test_export_directory_template_1():
# test export using directory template
locale.setlocale(locale.LC_ALL, "en_US")
setlocale(locale.LC_ALL, "en_US")
runner = CliRunner()
cwd = os.getcwd()
@ -3837,7 +3880,7 @@ def test_export_directory_template_locale():
with runner.isolated_filesystem():
# set locale environment
os.environ["LC_ALL"] = "de_DE.UTF-8"
locale.setlocale(locale.LC_ALL, "")
setlocale(locale.LC_ALL, "")
result = runner.invoke(
export,
[
@ -3857,7 +3900,7 @@ def test_export_directory_template_locale():
def test_export_filename_template_1():
"""export photos using filename template"""
locale.setlocale(locale.LC_ALL, "en_US")
setlocale(locale.LC_ALL, "en_US")
runner = CliRunner()
cwd = os.getcwd()
@ -3882,7 +3925,7 @@ def test_export_filename_template_1():
def test_export_filename_template_2():
"""export photos using filename template with folder_album and path_sep"""
locale.setlocale(locale.LC_ALL, "en_US")
setlocale(locale.LC_ALL, "en_US")
runner = CliRunner()
cwd = os.getcwd()
@ -3907,7 +3950,7 @@ def test_export_filename_template_2():
def test_export_filename_template_strip():
"""export photos using filename template with --strip"""
locale.setlocale(locale.LC_ALL, "en_US")
setlocale(locale.LC_ALL, "en_US")
runner = CliRunner()
cwd = os.getcwd()
@ -3933,7 +3976,7 @@ def test_export_filename_template_strip():
def test_export_filename_template_pathsep_in_name_1():
"""export photos using filename template with folder_album and "/" in album name"""
locale.setlocale(locale.LC_ALL, "en_US")
setlocale(locale.LC_ALL, "en_US")
runner = CliRunner()
cwd = os.getcwd()
@ -3960,7 +4003,7 @@ def test_export_filename_template_pathsep_in_name_1():
def test_export_filename_template_pathsep_in_name_2():
"""export photos using filename template with keyword and "/" in keyword"""
locale.setlocale(locale.LC_ALL, "en_US")
setlocale(locale.LC_ALL, "en_US")
runner = CliRunner()
cwd = os.getcwd()
@ -3988,7 +4031,7 @@ def test_export_filename_template_pathsep_in_name_2():
def test_export_filename_template_long_description():
"""export photos using filename template with description that exceeds max length"""
locale.setlocale(locale.LC_ALL, "en_US")
setlocale(locale.LC_ALL, "en_US")
runner = CliRunner()
cwd = os.getcwd()
@ -4664,7 +4707,6 @@ def test_export_force_update():
export, [os.path.join(cwd, photos_db_path), ".", "--force-update"]
)
assert result.exit_code == 0
print(result.output)
assert (
f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 0, updated: 0, skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, updated EXIF data: 0, missing: 3, error: 0"
in result.output
@ -4891,7 +4933,7 @@ def test_export_update_hardlink():
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
with isolated_filesystem_here():
# basic export
result = runner.invoke(
export,
@ -4924,7 +4966,7 @@ def test_export_update_hardlink_exiftool():
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
with isolated_filesystem_here():
# basic export
result = runner.invoke(
export,
@ -5069,7 +5111,7 @@ def test_export_then_hardlink():
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
with isolated_filesystem_here():
# basic export
result = runner.invoke(export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V"])
assert result.exit_code == 0
@ -5177,7 +5219,7 @@ def test_export_update_edits_dry_run():
def test_export_directory_template_1_dry_run():
"""test export using directory template with dry-run flag"""
locale.setlocale(locale.LC_ALL, "en_US")
setlocale(locale.LC_ALL, "en_US")
runner = CliRunner()
cwd = os.getcwd()
@ -6052,7 +6094,7 @@ def test_export_as_hardlink_download_missing():
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
with isolated_filesystem_here():
result = runner.invoke(
export,
[
@ -6877,6 +6919,7 @@ def test_export_finder_tag_keywords_dry_run():
assert result.exit_code == 0
@pytest.mark.skipif(not is_macos, reason="Only works on macOS")
def test_export_finder_tag_keywords():
"""test --finder-tag-keywords"""
@ -6951,6 +6994,7 @@ def test_export_finder_tag_keywords():
assert sorted(md.tags) == sorted(expected)
@pytest.mark.skipif(not is_macos, reason="Only works on macOS")
def test_export_finder_tag_template():
"""test --finder-tag-template"""
@ -7028,6 +7072,7 @@ def test_export_finder_tag_template():
assert sorted(md.tags) == sorted(expected)
@pytest.mark.skipif(not is_macos, reason="Only works on macOS")
def test_export_finder_tag_template_multiple():
"""test --finder-tag-template used more than once"""
@ -7061,6 +7106,7 @@ def test_export_finder_tag_template_multiple():
assert sorted(md.tags) == sorted(expected)
@pytest.mark.skipif(not is_macos, reason="Only works on macOS")
def test_export_finder_tag_template_keywords():
"""test --finder-tag-template with --finder-tag-keywords"""
@ -7093,6 +7139,7 @@ def test_export_finder_tag_template_keywords():
assert sorted(md.tags) == sorted(expected)
@pytest.mark.skipif(not is_macos, reason="Only works on macOS")
def test_export_finder_tag_template_multi_field():
"""test --finder-tag-template with multiple fields (issue #422)"""
@ -7157,6 +7204,7 @@ def test_export_xattr_template_dry_run():
assert "Writing extended attribute" in result.output
@pytest.mark.skipif(not is_macos, reason="Only works on macOS")
def test_export_xattr_template():
"""test --xattr template"""
@ -7655,14 +7703,14 @@ def test_query_name_unicode():
cwd = os.getcwd()
result = runner.invoke(
query,
["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--name", "Frtest"],
["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--name", "Frítest"],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 4
assert normalize_unicode(json_got[0]["original_filename"]).startswith(
normalize_unicode("Frtest.jpg")
normalize_unicode("Frítest.jpg")
)

View File

@ -1,10 +1,14 @@
"""Test osxphotos add-locations command"""
import photoscript
import pytest
from click.testing import CliRunner
from osxphotos.cli.add_locations import add_locations
from osxphotos.utils import is_macos
if is_macos:
import photoscript
from osxphotos.cli.add_locations import add_locations
else:
pytest.skip(allow_module_level=True)
UUID_TEST_PHOTO_1 = "F12384F6-CD17-4151-ACBA-AE0E3688539E" # Pumkins1.jpg
UUID_TEST_PHOTO_LOCATION = "D79B8D77-BFFC-460B-9312-034F2877D35B" # Pumkins2.jpg

View File

@ -2,10 +2,15 @@
import os
import photoscript
import pytest
from click.testing import CliRunner
from osxphotos.utils import is_macos
if is_macos:
import photoscript
else:
pytest.skip(allow_module_level=True)
UUID_EXPORT = {"3DD2C897-F19E-4CA6-8C22-B027D5A71907": {"filename": "IMG_4547.jpg"}}
UUID_MISSING = {
"8E1D7BC9-9321-44F9-8CFB-4083F6B9232A": {"filename": "IMG_2000.JPGssss"}

View File

@ -24,7 +24,6 @@ TEST_RUN_SCRIPT = "examples/cli_example_1.py"
def runner() -> CliRunner:
return CliRunner()
from osxphotos.cli import (
about,
albums,
@ -42,10 +41,14 @@ from osxphotos.cli import (
places,
theme,
tutorial,
uuid,
version,
)
from osxphotos.utils import is_macos
if is_macos:
from osxphotos.cli import uuid
def test_about(runner: CliRunner):
with runner.isolated_filesystem():
@ -68,9 +71,8 @@ def test_about(runner: CliRunner):
persons,
places,
tutorial,
uuid,
version,
],
] + ([uuid] if is_macos else []),
)
def test_cli_comands(runner: CliRunner, command: Callable[..., Any]):
with runner.isolated_filesystem():

View File

@ -5,12 +5,17 @@ from __future__ import annotations
import os
import time
import photoscript
import pytest
from click.testing import CliRunner
import osxphotos
from osxphotos.cli.batch_edit import batch_edit
from osxphotos.utils import is_macos
if is_macos:
import photoscript
from osxphotos.cli.batch_edit import batch_edit
else:
pytest.skip(allow_module_level=True)
# set timezone to avoid issues with comparing dates
os.environ["TZ"] = "US/Pacific"

View File

@ -14,17 +14,23 @@ from tempfile import TemporaryDirectory
from typing import Dict
import pytest
from click.testing import CliRunner
from photoscript import Photo
from pytest import MonkeyPatch, approx
from osxphotos import PhotosDB, QueryOptions
from osxphotos._constants import UUID_PATTERN
from osxphotos.cli.import_cli import import_cli
from osxphotos.datetime_utils import datetime_remove_tz
from osxphotos.exiftool import get_exiftool_path
from osxphotos.utils import is_macos
from tests.conftest import get_os_version
if is_macos:
from photoscript import Photo
from osxphotos.cli.import_cli import import_cli
else:
pytest.skip(allow_module_level=True)
TERMINAL_WIDTH = 250
TEST_IMAGES_DIR = "tests/test-images"

View File

@ -2,11 +2,15 @@
import os
import json
import photoscript
import pytest
from click.testing import CliRunner
from osxphotos.cli.sync import sync
from osxphotos.utils import is_macos
if is_macos:
import photoscript
from osxphotos.cli.sync import sync
else:
pytest.skip(allow_module_level=True)
UUID_TEST_PHOTO_1 = "D79B8D77-BFFC-460B-9312-034F2877D35B" # Pumkins2.jpg
UUID_TEST_PHOTO_2 = "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51" # wedding.jpg

View File

@ -1,6 +1,8 @@
""" test datetime_formatter.DateTimeFormatter """
import pytest
from .locale_util import setlocale
def test_datetime_formatter_1():
"""Test DateTimeFormatter """
@ -8,7 +10,7 @@ def test_datetime_formatter_1():
import locale
from osxphotos.datetime_formatter import DateTimeFormatter
locale.setlocale(locale.LC_ALL, "en_US")
setlocale(locale.LC_ALL, "en_US")
dt = datetime.datetime(2020, 5, 23, 12, 42, 33)
dtf = DateTimeFormatter(dt)
@ -32,7 +34,7 @@ def test_datetime_formatter_2():
import locale
from osxphotos.datetime_formatter import DateTimeFormatter
locale.setlocale(locale.LC_ALL, "en_US")
setlocale(locale.LC_ALL, "en_US")
dt = datetime.datetime(2020, 5, 23, 14, 42, 33)
dtf = DateTimeFormatter(dt)
@ -56,7 +58,7 @@ def test_datetime_formatter_3():
import locale
from osxphotos.datetime_formatter import DateTimeFormatter
locale.setlocale(locale.LC_ALL, "en_US")
setlocale(locale.LC_ALL, "en_US")
dt = datetime.datetime(2020, 5, 2, 9, 3, 6)
dtf = DateTimeFormatter(dt)

View File

@ -538,13 +538,13 @@ def test_exiftool_terminate():
ps = subprocess.run(["ps"], capture_output=True)
stdout = ps.stdout.decode("utf-8")
assert "exiftool -stay_open" in stdout
assert "exiftool" in stdout
osxphotos.exiftool.terminate_exiftool()
ps = subprocess.run(["ps"], capture_output=True)
stdout = ps.stdout.decode("utf-8")
assert "exiftool -stay_open" not in stdout
assert "exiftool" not in stdout
# verify we can create a new instance after termination
exif2 = osxphotos.exiftool.ExifTool(TEST_FILE_ONE_KEYWORD)

View File

@ -2,9 +2,9 @@ import os
import pytest
from osxphotos._constants import _UNKNOWN_PERSON
from osxphotos.utils import get_macos_version
from osxphotos.utils import is_macos, get_macos_version
OS_VERSION = get_macos_version()
OS_VERSION = get_macos_version() if is_macos else (None, None, None)
SKIP_TEST = "OSXPHOTOS_TEST_EXPORT" not in os.environ or OS_VERSION[1] != "15"
PHOTOS_DB = os.path.expanduser("~/Pictures/Photos Library.photoslibrary")
pytestmark = pytest.mark.skipif(

View File

@ -74,10 +74,12 @@ def test_hardlink_file_valid():
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
src = "tests/test-images/wedding.jpg"
src2 = os.path.join(temp_dir.name, "wedding_src.jpg")
dest = os.path.join(temp_dir.name, "wedding.jpg")
FileUtil.hardlink(src, dest)
FileUtil.copy(src, src2)
FileUtil.hardlink(src2, dest)
assert os.path.isfile(dest)
assert os.path.samefile(src, dest)
assert os.path.samefile(src2, dest)
def test_unlink_file():

View File

@ -14,9 +14,9 @@ import pytest
import osxphotos
from osxphotos._constants import _UNKNOWN_PERSON
from osxphotos.photoexporter import PhotoExporter
from osxphotos.utils import get_macos_version
from osxphotos.utils import is_macos, get_macos_version
OS_VERSION = get_macos_version()
OS_VERSION = get_macos_version() if is_macos else (None, None, None)
# SKIP_TEST = "OSXPHOTOS_TEST_EXPORT" not in os.environ or OS_VERSION[1] != "17"
SKIP_TEST = True # don't run any of the local library tests
PHOTOS_DB_LOCAL = os.path.expanduser("~/Pictures/Photos Library.photoslibrary")

View File

@ -6,15 +6,19 @@ import tempfile
import pytest
from osxphotos.photokit import (
LivePhotoAsset,
PhotoAsset,
PhotoLibrary,
VideoAsset,
PHOTOS_VERSION_CURRENT,
PHOTOS_VERSION_ORIGINAL,
PHOTOS_VERSION_UNADJUSTED,
)
from osxphotos.utils import is_macos
if is_macos:
from osxphotos.photokit import (
LivePhotoAsset,
PhotoAsset,
PhotoLibrary,
VideoAsset,
PHOTOS_VERSION_CURRENT,
PHOTOS_VERSION_ORIGINAL,
PHOTOS_VERSION_UNADJUSTED,
)
else:
pytest.skip(allow_module_level=True)
skip_test = "OSXPHOTOS_TEST_EXPORT" not in os.environ
pytestmark = pytest.mark.skipif(

View File

@ -15,7 +15,9 @@ from osxphotos.phototemplate import (
RenderOptions,
)
from osxphotos.photoinfo import PhotoInfoNone
from osxphotos.utils import is_macos
from .photoinfo_mock import PhotoInfoMock
from .locale_util import setlocale
try:
exiftool = get_exiftool_path()
@ -549,6 +551,8 @@ def test_lookup_multi(photosdb_places):
lookup_str = re.match(r"\{([^\\,}]+)\}", subst).group(1)
if subst in ["{exiftool}", "{photo}", "{function}", "{format}"]:
continue
if subst == "{detected_text}" and not is_macos:
continue
lookup = template.get_template_value_multi(
lookup_str,
path_sep=os.path.sep,
@ -562,7 +566,7 @@ def test_subst(photosdb_places):
"""Test that substitutions are correct"""
import locale
locale.setlocale(locale.LC_ALL, "en_US")
setlocale(locale.LC_ALL, "en_US")
photo = photosdb_places.photos(uuid=[UUID_DICT["place_dc"]])[0]
for template in TEMPLATE_VALUES:
@ -574,7 +578,7 @@ def test_subst_date_modified(photosdb_places):
"""Test that substitutions are correct for date modified"""
import locale
locale.setlocale(locale.LC_ALL, "en_US")
setlocale(locale.LC_ALL, "en_US")
photo = photosdb_places.photos(uuid=[UUID_DICT["date_modified"]])[0]
for template in TEMPLATE_VALUES_DATE_MODIFIED:
@ -586,7 +590,7 @@ def test_subst_date_not_modified(photosdb_places):
"""Test that substitutions are correct for date modified when photo isn't modified"""
import locale
locale.setlocale(locale.LC_ALL, "en_US")
setlocale(locale.LC_ALL, "en_US")
photo = photosdb_places.photos(uuid=[UUID_DICT["date_not_modified"]])[0]
for template in TEMPLATE_VALUES_DATE_NOT_MODIFIED:
@ -600,7 +604,7 @@ def test_subst_locale_1(photosdb_places):
# osxphotos.template sets local on load so set the environment first
# set locale to DE
locale.setlocale(locale.LC_ALL, "de_DE.UTF-8")
setlocale(locale.LC_ALL, "de_DE.UTF-8")
photo = photosdb_places.photos(uuid=[UUID_DICT["place_dc"]])[0]
@ -614,6 +618,9 @@ def test_subst_locale_2(photosdb_places):
import locale
import os
# Check if locale is available
setlocale(locale.LC_ALL, "de_DE.UTF-8")
# osxphotos.template sets local on load so set the environment first
os.environ["LANG"] = "de_DE.UTF-8"
os.environ["LC_COLLATE"] = "de_DE.UTF-8"
@ -634,7 +641,7 @@ def test_subst_default_val(photosdb_places):
"""Test substitution with default value specified"""
import locale
locale.setlocale(locale.LC_ALL, "en_US")
setlocale(locale.LC_ALL, "en_US")
photo = photosdb_places.photos(uuid=[UUID_DICT["place_dc"]])[0]
template = "{place.name.area_of_interest,UNKNOWN}"
@ -646,7 +653,7 @@ def test_subst_default_val_2(photosdb_places):
"""Test substitution with ',' but no default value"""
import locale
locale.setlocale(locale.LC_ALL, "en_US")
setlocale(locale.LC_ALL, "en_US")
photo = photosdb_places.photos(uuid=[UUID_DICT["place_dc"]])[0]
template = "{place.name.area_of_interest,}"
@ -658,7 +665,7 @@ def test_subst_unknown_val(photosdb_places):
"""Test substitution with unknown value specified"""
import locale
locale.setlocale(locale.LC_ALL, "en_US")
setlocale(locale.LC_ALL, "en_US")
photo = photosdb_places.photos(uuid=[UUID_DICT["place_dc"]])[0]
template = "{created.year}/{foo}"
@ -682,7 +689,7 @@ def test_subst_unknown_val_with_default(photosdb_places):
"""Test substitution with unknown value specified"""
import locale
locale.setlocale(locale.LC_ALL, "en_US")
setlocale(locale.LC_ALL, "en_US")
photo = photosdb_places.photos(uuid=[UUID_DICT["place_dc"]])[0]
template = "{created.year}/{foo,bar}"
@ -929,7 +936,7 @@ def test_subst_strftime(photosdb_places):
"""Test that strftime substitutions are correct"""
import locale
locale.setlocale(locale.LC_ALL, "en_US")
setlocale(locale.LC_ALL, "en_US")
photo = photosdb_places.photos(uuid=[UUID_DICT["place_dc"]])[0]
rendered, unmatched = photo.render_template("{created.strftime,%Y-%m-%d-%H%M%S}")
@ -1287,6 +1294,7 @@ def test_album_seq(photosdb):
assert rendered[0] == value
@pytest.mark.skipif(not is_macos, reason="Only works on macOS")
def test_detected_text(photosdb):
"""Test {detected_text} template"""
photo = photosdb.get_photo(UUID_DETECTED_TEXT)

View File

@ -5,6 +5,8 @@ import pytest
import osxphotos
from osxphotos.phototemplate import RenderOptions
from .locale_util import setlocale
PHOTOS_DB_PLACES = (
"./tests/Test-Places-Catalina-10_15_1.photoslibrary/database/photos.db"
)
@ -48,7 +50,7 @@ def test_subst_today(photosdb):
"""Test that substitutions are correct for {today.x}"""
import locale
locale.setlocale(locale.LC_ALL, "en_US")
setlocale(locale.LC_ALL, "en_US")
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
photo_template = osxphotos.PhotoTemplate(photo)
@ -64,7 +66,7 @@ def test_subst_strftime_today(photosdb):
"""Test that strftime substitutions are correct for {today.strftime}"""
import locale
locale.setlocale(locale.LC_ALL, "en_US")
setlocale(locale.LC_ALL, "en_US")
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
photo_template = osxphotos.PhotoTemplate(photo)

View File

@ -8,6 +8,7 @@ from osxphotos.uti import (
get_preferred_uti_extension,
get_uti_for_extension,
)
from osxphotos.utils import is_macos
EXT_DICT = {"heic": "public.heic", "jpg": "public.jpeg", ".jpg": "public.jpeg"}
UTI_DICT = {"public.heic": "heic", "public.jpeg": "jpeg"}
@ -43,12 +44,14 @@ def test_get_uti_for_extension_no_objc():
osxphotos.uti.OS_VER = OLD_VER
@pytest.mark.skipif(not is_macos, reason="Only works on macOS")
def test_get_uti_from_mdls():
"""get _get_uti_from_mdls"""
for ext in EXT_DICT:
assert _get_uti_from_mdls(ext) == EXT_DICT[ext]
@pytest.mark.skipif(not is_macos, reason="Only works on macOS")
def test_get_uti_not_in_dict():
"""get UTI when objc is not available and it's not in the EXT_UTI_DICT"""
# monkey patch the EXT_UTI_DICT