Fixed from_date and to_date to be timezone aware, closes #193

This commit is contained in:
Rhet Turnbull
2020-08-08 21:01:53 -07:00
parent 2628c1f2d2
commit fc416ea0b7
14 changed files with 199 additions and 96 deletions

View File

@@ -77,6 +77,20 @@ def get_photos_db(*db_options):
return None return None
class DateTimeISO8601(click.ParamType):
name = "DATETIME"
def convert(self, value, param, ctx):
try:
return datetime.datetime.fromisoformat(value)
except:
self.fail(
f"Invalid value for --{param.name}: invalid datetime format {value}. "
"Valid format: YYYY-MM-DD[*HH[:MM[:SS[.fff[fff]]]][+HH:MM[:SS[.ffffff]]]]"
)
# Click CLI object & context settings # Click CLI object & context settings
class CLI_Obj: class CLI_Obj:
def __init__(self, db=None, json=False, debug=False): def __init__(self, db=None, json=False, debug=False):
@@ -305,7 +319,7 @@ def query_options(f):
multiple=False, multiple=False,
help="Search for photos with UUID(s) loaded from FILE. " help="Search for photos with UUID(s) loaded from FILE. "
"Format is a single UUID per line. Lines preceeded with # are ignored.", "Format is a single UUID per line. Lines preceeded with # are ignored.",
type=click.Path(exists=True) type=click.Path(exists=True),
), ),
o( o(
"--title", "--title",
@@ -454,13 +468,13 @@ def query_options(f):
), ),
o( o(
"--from-date", "--from-date",
help="Search by start item date, e.g. 2000-01-12T12:00:00 or 2000-12-31 (ISO 8601 w/o TZ).", help="Search by start item date, e.g. 2000-01-12T12:00:00, 2001-01-12T12:00:00-07:00, or 2000-12-31 (ISO 8601).",
type=click.DateTime(), type=DateTimeISO8601(),
), ),
o( o(
"--to-date", "--to-date",
help="Search by end item date, e.g. 2000-01-12T12:00:00 or 2000-12-31 (ISO 8601 w/o TZ).", help="Search by end item date, e.g. 2000-01-12T12:00:00, 2001-01-12T12:00:00-07:00, or 2000-12-31 (ISO 8601).",
type=click.DateTime(), type=DateTimeISO8601(),
), ),
] ]
for o in options[::-1]: for o in options[::-1]:
@@ -1011,7 +1025,7 @@ def query(
# load UUIDs if necessary and append to any uuids passed with --uuid # load UUIDs if necessary and append to any uuids passed with --uuid
if uuid_from_file: if uuid_from_file:
uuid_list = list(uuid) # Click option is a tuple uuid_list = list(uuid) # Click option is a tuple
uuid_list.extend(load_uuid_from_file(uuid_from_file)) uuid_list.extend(load_uuid_from_file(uuid_from_file))
uuid = tuple(uuid_list) uuid = tuple(uuid_list)
@@ -1401,7 +1415,7 @@ def export(
# load UUIDs if necessary and append to any uuids passed with --uuid # load UUIDs if necessary and append to any uuids passed with --uuid
if uuid_from_file: if uuid_from_file:
uuid_list = list(uuid) # Click option is a tuple uuid_list = list(uuid) # Click option is a tuple
uuid_list.extend(load_uuid_from_file(uuid_from_file)) uuid_list.extend(load_uuid_from_file(uuid_from_file))
uuid = tuple(uuid_list) uuid = tuple(uuid_list)
@@ -2363,6 +2377,7 @@ def find_files_in_branch(pathname, filename):
return files return files
def load_uuid_from_file(filename): def load_uuid_from_file(filename):
""" Load UUIDs from file. Does not validate UUIDs. """ Load UUIDs from file. Does not validate UUIDs.
Format is 1 UUID per line, any line beginning with # is ignored. Format is 1 UUID per line, any line beginning with # is ignored.
@@ -2377,7 +2392,7 @@ def load_uuid_from_file(filename):
Raises: Raises:
FileNotFoundError if file does not exist FileNotFoundError if file does not exist
""" """
if not pathlib.Path(filename).is_file(): if not pathlib.Path(filename).is_file():
raise FileNotFoundError(f"Could not find file {filename}") raise FileNotFoundError(f"Could not find file {filename}")
@@ -2389,5 +2404,6 @@ def load_uuid_from_file(filename):
uuid.append(line) uuid.append(line)
return uuid return uuid
if __name__ == "__main__": if __name__ == "__main__":
cli() # pylint: disable=no-value-for-parameter cli() # pylint: disable=no-value-for-parameter

View File

@@ -1,3 +1,3 @@
""" version info """ """ version info """
__version__ = "0.31.1" __version__ = "0.31.2"

View File

@@ -0,0 +1,57 @@
""" datetime utilities """
import datetime
def get_local_tz():
""" return local timezone as datetime.timezone tzinfo """
local_tz = (
datetime.datetime.now(datetime.timezone(datetime.timedelta(0)))
.astimezone()
.tzinfo
)
return local_tz
def datetime_remove_tz(dt):
""" remove timezone from a datetime.datetime object
dt: datetime.datetime object with tzinfo
returns: dt without any timezone info (naive datetime object) """
if type(dt) != datetime.datetime:
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
dt_new = dt.replace(tzinfo=None)
return dt_new
def datetime_has_tz(dt):
""" return True if datetime dt has tzinfo else False
dt: datetime.datetime
returns True if dt is timezone aware, else False """
if type(dt) != datetime.datetime:
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
if dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None:
return True
return False
def datetime_naive_to_local(dt):
""" convert naive (timezone unaware) datetime.datetime
to aware timezone in local timezone
dt: datetime.datetime without timezone
returns: datetime.datetime with local timezone """
if type(dt) != datetime.datetime:
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
if dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None:
# has timezone info
raise ValueError(
"dt must be naive/timezone unaware: "
f"{dt} has tzinfo {dt.tzinfo} and offset {dt.tizinfo.utcoffset(dt)}"
)
dt_local = dt.replace(tzinfo=get_local_tz())
return dt_local

View File

@@ -87,12 +87,8 @@ class PhotoInfo:
@property @property
def date(self): def date(self):
""" image creation date as timezone aware datetime object """ """ image creation date as timezone aware datetime object """
imagedate = self._info["imageDate"] return self._info["imageDate"]
seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
delta = timedelta(seconds=seconds)
tz = timezone(delta)
return imagedate.astimezone(tz=tz)
@property @property
def date_modified(self): def date_modified(self):
""" image modification date as timezone aware datetime object """ image modification date as timezone aware datetime object

View File

@@ -11,7 +11,7 @@ import platform
import sqlite3 import sqlite3
import sys import sys
import tempfile import tempfile
from datetime import datetime from datetime import datetime, timedelta, timezone
from pprint import pformat from pprint import pformat
from shutil import copyfile from shutil import copyfile
@@ -34,6 +34,7 @@ from .._constants import (
) )
from .._version import __version__ from .._version import __version__
from ..albuminfo import AlbumInfo, FolderInfo from ..albuminfo import AlbumInfo, FolderInfo
from ..datetime_utils import datetime_has_tz, datetime_naive_to_local
from ..personinfo import PersonInfo from ..personinfo import PersonInfo
from ..photoinfo import PhotoInfo from ..photoinfo import PhotoInfo
from ..utils import ( from ..utils import (
@@ -952,15 +953,23 @@ class PhotosDB:
except TypeError: except TypeError:
self._dbphotos[uuid]["lastmodifieddate"] = None self._dbphotos[uuid]["lastmodifieddate"] = None
self._dbphotos[uuid]["imageTimeZoneOffsetSeconds"] = row[9]
try: try:
self._dbphotos[uuid]["imageDate"] = datetime.fromtimestamp(row[5] + td) imagedate = datetime.fromtimestamp(row[5] + td)
seconds = self._dbphotos[uuid]["imageTimeZoneOffsetSeconds"] or 0
delta = timedelta(seconds=seconds)
tz = timezone(delta)
self._dbphotos[uuid]["imageDate"] = imagedate.astimezone(tz=tz)
except ValueError: except ValueError:
self._dbphotos[uuid]["imageDate"] = datetime(1970, 1, 1) # sometimes imageDate is invalid so use 1 Jan 1970 in UTC as image date
imagedate = datetime(1970, 1, 1)
tz = timezone(timedelta(0))
self._dbphotos[uuid]["imageDate"] = imagedate.astimezone(tz=tz)
self._dbphotos[uuid]["mainRating"] = row[6] self._dbphotos[uuid]["mainRating"] = row[6]
self._dbphotos[uuid]["hasAdjustments"] = row[7] self._dbphotos[uuid]["hasAdjustments"] = row[7]
self._dbphotos[uuid]["hasKeywords"] = row[8] self._dbphotos[uuid]["hasKeywords"] = row[8]
self._dbphotos[uuid]["imageTimeZoneOffsetSeconds"] = row[9]
self._dbphotos[uuid]["volumeId"] = row[10] self._dbphotos[uuid]["volumeId"] = row[10]
self._dbphotos[uuid]["imagePath"] = row[11] self._dbphotos[uuid]["imagePath"] = row[11]
self._dbphotos[uuid]["extendedDescription"] = row[12] self._dbphotos[uuid]["extendedDescription"] = row[12]
@@ -1329,7 +1338,7 @@ class PhotosDB:
# process faces # process faces
self._process_faceinfo() self._process_faceinfo()
# add faces and keywords to photo data # add faces and keywords to photo data
for uuid in self._dbphotos: for uuid in self._dbphotos:
# keywords # keywords
@@ -1788,12 +1797,20 @@ class PhotosDB:
except TypeError: except TypeError:
info["lastmodifieddate"] = None info["lastmodifieddate"] = None
try:
info["imageDate"] = datetime.fromtimestamp(row[5] + td)
except ValueError:
info["imageDate"] = datetime(1970, 1, 1)
info["imageTimeZoneOffsetSeconds"] = row[6] info["imageTimeZoneOffsetSeconds"] = row[6]
try:
imagedate = datetime.fromtimestamp(row[5] + td)
seconds = info["imageTimeZoneOffsetSeconds"] or 0
delta = timedelta(seconds=seconds)
tz = timezone(delta)
info["imageDate"] = imagedate.astimezone(tz=tz)
except ValueError:
# sometimes imageDate is invalid so use 1 Jan 1970 in UTC as image date
imagedate = datetime(1970, 1, 1)
tz = timezone(timedelta(0))
info["imageDate"] = imagedate.astimezone(tz=tz)
info["hidden"] = row[9] info["hidden"] = row[9]
info["favorite"] = row[10] info["favorite"] = row[10]
info["originalFilename"] = row[3] info["originalFilename"] = row[3]
@@ -2135,7 +2152,7 @@ class PhotosDB:
# process face info # process face info
self._process_faceinfo() self._process_faceinfo()
# process search info # process search info
self._process_searchinfo() self._process_searchinfo()
@@ -2436,23 +2453,29 @@ class PhotosDB:
to_date=None, to_date=None,
intrash=False, intrash=False,
): ):
""" """ Return a list of PhotoInfo objects
Return a list of PhotoInfo objects
If called with no args, returns the entire database of photos If called with no args, returns the entire database of photos
If called with args, returns photos matching the args (e.g. keywords, persons, etc.) If called with args, returns photos matching the args (e.g. keywords, persons, etc.)
If more than one arg, returns photos matching all the criteria (e.g. keywords AND persons) If more than one arg, returns photos matching all the criteria (e.g. keywords AND persons)
If more than one keyword, uuid, persons, albums is passed, they are treated as "OR" criteria If more than one keyword, uuid, persons, albums is passed, they are treated as "OR" criteria
e.g. keywords=["wedding","vacation"] returns photos matching either keyword e.g. keywords=["wedding","vacation"] returns photos matching either keyword
keywords: list of keywords to search for from_date and to_date may be either naive or timezone-aware datetime.datetime objects.
uuid: list of UUIDs to search for If naive, timezone will be assumed to be local timezone.
persons: list of persons to search for
albums: list of album names to search for Args:
images: if True, returns image files, if False, does not return images; default is True keywords: list of keywords to search for
movies: if True, returns movie files, if False, does not return movies; default is True uuid: list of UUIDs to search for
from_date: return photos with creation date >= from_date (datetime.datetime object, default None) persons: list of persons to search for
to_date: return photos with creation date <= to_date (datetime.datetime object, default None) albums: list of album names to search for
intrash: if True, returns only images in "Recently deleted items" folder, images: if True, returns image files, if False, does not return images; default is True
if False returns only photos that aren't deleted; default is False movies: if True, returns movie files, if False, does not return movies; default is True
from_date: return photos with creation date >= from_date (datetime.datetime object, default None)
to_date: return photos with creation date <= to_date (datetime.datetime object, default None)
intrash: if True, returns only images in "Recently deleted items" folder,
if False returns only photos that aren't deleted; default is False
Returns:
list of PhotoInfo objects
""" """
# implementation is a bit kludgy but it works # implementation is a bit kludgy but it works
@@ -2528,6 +2551,8 @@ class PhotosDB:
if from_date or to_date: # sourcery off if from_date or to_date: # sourcery off
dsel = self._dbphotos dsel = self._dbphotos
if from_date: if from_date:
if not datetime_has_tz(from_date):
from_date = datetime_naive_to_local(from_date)
dsel = { dsel = {
k: v for k, v in dsel.items() if v["imageDate"] >= from_date k: v for k, v in dsel.items() if v["imageDate"] >= from_date
} }
@@ -2535,6 +2560,8 @@ class PhotosDB:
f"Found %i items with from_date {from_date}" % len(dsel) f"Found %i items with from_date {from_date}" % len(dsel)
) )
if to_date: if to_date:
if not datetime_has_tz(to_date):
to_date = datetime_naive_to_local(to_date)
dsel = {k: v for k, v in dsel.items() if v["imageDate"] <= to_date} dsel = {k: v for k, v in dsel.items() if v["imageDate"] <= to_date}
logging.debug(f"Found %i items with to_date {to_date}" % len(dsel)) logging.debug(f"Found %i items with to_date {to_date}" % len(dsel))
photos_sets.append(set(dsel.keys())) photos_sets.append(set(dsel.keys()))

View File

@@ -46,13 +46,6 @@ def test_db_version():
assert photosdb.db_version == "2622" assert photosdb.db_version == "2622"
def test_os_version():
import osxphotos
(_, major, _) = osxphotos.utils._get_os_version()
assert major in osxphotos._constants._TESTED_OS_VERSIONS
def test_persons(): def test_persons():
import osxphotos import osxphotos
import collections import collections

View File

@@ -130,13 +130,6 @@ def test_db_version():
assert photosdb.db_version == "6000" assert photosdb.db_version == "6000"
def test_os_version():
import osxphotos
(_, major, _) = osxphotos.utils._get_os_version()
assert major in osxphotos._constants._TESTED_OS_VERSIONS
def test_persons(): def test_persons():
import osxphotos import osxphotos
import collections import collections

View File

@@ -138,13 +138,6 @@ def test_db_version():
assert photosdb.db_version == "6000" assert photosdb.db_version == "6000"
def test_os_version():
import osxphotos
(_, major, _) = osxphotos.utils._get_os_version()
assert major in osxphotos._constants._TESTED_OS_VERSIONS
def test_persons(): def test_persons():
import osxphotos import osxphotos
import collections import collections

View File

@@ -164,13 +164,6 @@ def test_db_version():
assert photosdb.db_version == "6000" assert photosdb.db_version == "6000"
def test_os_version():
import osxphotos
(_, major, _) = osxphotos.utils._get_os_version()
assert major in osxphotos._constants._TESTED_OS_VERSIONS
def test_persons(): def test_persons():
import osxphotos import osxphotos
import collections import collections

View File

@@ -697,13 +697,18 @@ def test_export_edited_suffix():
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_EDITED_SUFFIX) assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_EDITED_SUFFIX)
def test_query_date(): def test_query_date_1():
""" Test --from-date and --to-date """
import json import json
import osxphotos import osxphotos
import os import os
import os.path import os.path
import time
from osxphotos.__main__ import query from osxphotos.__main__ import query
os.environ['TZ'] = "US/Pacific"
time.tzset()
runner = CliRunner() runner = CliRunner()
cwd = os.getcwd() cwd = os.getcwd()
result = runner.invoke( result = runner.invoke(
@@ -721,6 +726,64 @@ def test_query_date():
json_got = json.loads(result.output) json_got = json.loads(result.output)
assert len(json_got) == 4 assert len(json_got) == 4
def test_query_date_2():
""" Test --from-date and --to-date """
import json
import osxphotos
import os
import os.path
import time
from osxphotos.__main__ import query
os.environ['TZ'] = "Asia/Jerusalem"
time.tzset()
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
[
"--json",
"--db",
os.path.join(cwd, CLI_PHOTOS_DB),
"--from-date=2018-09-28",
"--to-date=2018-09-28T23:00:00",
],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 2
def test_query_date_timezone():
""" Test --from-date, --to-date with ISO 8601 timezone """
import json
import osxphotos
import os
import os.path
import time
from osxphotos.__main__ import query
os.environ['TZ'] = "US/Pacific"
time.tzset()
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
[
"--json",
"--db",
os.path.join(cwd, CLI_PHOTOS_DB),
"--from-date=2018-09-28T00:00:00-07:00",
"--to-date=2018-09-28T23:00:00-07:00",
],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 4
def test_query_keyword_1(): def test_query_keyword_1():
"""Test query --keyword """ """Test query --keyword """

View File

@@ -29,13 +29,6 @@ def test_db_version():
assert photosdb.db_version == "4025" assert photosdb.db_version == "4025"
def test_os_version():
import osxphotos
(_, major, _) = osxphotos.utils._get_os_version()
assert major in osxphotos._constants._TESTED_OS_VERSIONS
def test_persons(): def test_persons():
import osxphotos import osxphotos
import collections import collections

View File

@@ -46,13 +46,6 @@ def test_db_version():
# assert photosdb.db_version in osxphotos._TESTED_DB_VERSIONS # assert photosdb.db_version in osxphotos._TESTED_DB_VERSIONS
def test_os_version():
import osxphotos
(_, major, _) = osxphotos.utils._get_os_version()
assert major in osxphotos._constants._TESTED_OS_VERSIONS
def test_persons(): def test_persons():
import osxphotos import osxphotos
import collections import collections

View File

@@ -47,13 +47,6 @@ def test_db_version():
assert photosdb.db_version == "4016" assert photosdb.db_version == "4016"
def test_os_version():
import osxphotos
(_, major, _) = osxphotos.utils._get_os_version()
assert major in osxphotos._constants._TESTED_OS_VERSIONS
def test_persons(): def test_persons():
import osxphotos import osxphotos
import collections import collections

View File

@@ -1,4 +1,3 @@
import pytest
from osxphotos._constants import _UNKNOWN_PERSON from osxphotos._constants import _UNKNOWN_PERSON
@@ -82,13 +81,6 @@ def test_db_len():
assert len(photosdb) == PHOTOS_DB_LEN assert len(photosdb) == PHOTOS_DB_LEN
def test_os_version():
import osxphotos
(_, major, _) = osxphotos.utils._get_os_version()
assert major in osxphotos._constants._TESTED_OS_VERSIONS
def test_persons(): def test_persons():
import osxphotos import osxphotos
import collections import collections
@@ -272,6 +264,7 @@ def test_not_hidden():
def test_location_1(): def test_location_1():
# test photo with lat/lon info # test photo with lat/lon info
import osxphotos import osxphotos
import pytest
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB) photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=["3Jn73XpSQQCluzRBMWRsMA"]) photos = photosdb.photos(uuid=["3Jn73XpSQQCluzRBMWRsMA"])