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
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
class CLI_Obj:
def __init__(self, db=None, json=False, debug=False):
@ -305,7 +319,7 @@ def query_options(f):
multiple=False,
help="Search for photos with UUID(s) loaded from FILE. "
"Format is a single UUID per line. Lines preceeded with # are ignored.",
type=click.Path(exists=True)
type=click.Path(exists=True),
),
o(
"--title",
@ -454,13 +468,13 @@ def query_options(f):
),
o(
"--from-date",
help="Search by start item date, e.g. 2000-01-12T12:00:00 or 2000-12-31 (ISO 8601 w/o TZ).",
type=click.DateTime(),
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=DateTimeISO8601(),
),
o(
"--to-date",
help="Search by end item date, e.g. 2000-01-12T12:00:00 or 2000-12-31 (ISO 8601 w/o TZ).",
type=click.DateTime(),
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=DateTimeISO8601(),
),
]
for o in options[::-1]:
@ -1011,7 +1025,7 @@ def query(
# load UUIDs if necessary and append to any uuids passed with --uuid
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 = tuple(uuid_list)
@ -1401,7 +1415,7 @@ def export(
# load UUIDs if necessary and append to any uuids passed with --uuid
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 = tuple(uuid_list)
@ -2363,6 +2377,7 @@ def find_files_in_branch(pathname, filename):
return files
def load_uuid_from_file(filename):
""" Load UUIDs from file. Does not validate UUIDs.
Format is 1 UUID per line, any line beginning with # is ignored.
@ -2377,7 +2392,7 @@ def load_uuid_from_file(filename):
Raises:
FileNotFoundError if file does not exist
"""
if not pathlib.Path(filename).is_file():
raise FileNotFoundError(f"Could not find file {filename}")
@ -2389,5 +2404,6 @@ def load_uuid_from_file(filename):
uuid.append(line)
return uuid
if __name__ == "__main__":
cli() # pylint: disable=no-value-for-parameter

View File

@ -1,3 +1,3 @@
""" 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
def date(self):
""" image creation date as timezone aware datetime object """
imagedate = self._info["imageDate"]
seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
delta = timedelta(seconds=seconds)
tz = timezone(delta)
return imagedate.astimezone(tz=tz)
return self._info["imageDate"]
@property
def date_modified(self):
""" image modification date as timezone aware datetime object

View File

@ -11,7 +11,7 @@ import platform
import sqlite3
import sys
import tempfile
from datetime import datetime
from datetime import datetime, timedelta, timezone
from pprint import pformat
from shutil import copyfile
@ -34,6 +34,7 @@ from .._constants import (
)
from .._version import __version__
from ..albuminfo import AlbumInfo, FolderInfo
from ..datetime_utils import datetime_has_tz, datetime_naive_to_local
from ..personinfo import PersonInfo
from ..photoinfo import PhotoInfo
from ..utils import (
@ -952,15 +953,23 @@ class PhotosDB:
except TypeError:
self._dbphotos[uuid]["lastmodifieddate"] = None
self._dbphotos[uuid]["imageTimeZoneOffsetSeconds"] = row[9]
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:
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]["hasAdjustments"] = row[7]
self._dbphotos[uuid]["hasKeywords"] = row[8]
self._dbphotos[uuid]["imageTimeZoneOffsetSeconds"] = row[9]
self._dbphotos[uuid]["volumeId"] = row[10]
self._dbphotos[uuid]["imagePath"] = row[11]
self._dbphotos[uuid]["extendedDescription"] = row[12]
@ -1329,7 +1338,7 @@ class PhotosDB:
# process faces
self._process_faceinfo()
# add faces and keywords to photo data
for uuid in self._dbphotos:
# keywords
@ -1788,12 +1797,20 @@ class PhotosDB:
except TypeError:
info["lastmodifieddate"] = None
try:
info["imageDate"] = datetime.fromtimestamp(row[5] + td)
except ValueError:
info["imageDate"] = datetime(1970, 1, 1)
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["favorite"] = row[10]
info["originalFilename"] = row[3]
@ -2135,7 +2152,7 @@ class PhotosDB:
# process face info
self._process_faceinfo()
# process search info
self._process_searchinfo()
@ -2436,23 +2453,29 @@ class PhotosDB:
to_date=None,
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 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 keyword, uuid, persons, albums is passed, they are treated as "OR" criteria
e.g. keywords=["wedding","vacation"] returns photos matching either keyword
keywords: list of keywords to search for
uuid: list of UUIDs to search for
persons: list of persons to search for
albums: list of album names to search for
images: if True, returns image files, if False, does not return images; default is True
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
from_date and to_date may be either naive or timezone-aware datetime.datetime objects.
If naive, timezone will be assumed to be local timezone.
Args:
keywords: list of keywords to search for
uuid: list of UUIDs to search for
persons: list of persons to search for
albums: list of album names to search for
images: if True, returns image files, if False, does not return images; default is True
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
@ -2528,6 +2551,8 @@ class PhotosDB:
if from_date or to_date: # sourcery off
dsel = self._dbphotos
if from_date:
if not datetime_has_tz(from_date):
from_date = datetime_naive_to_local(from_date)
dsel = {
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)
)
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}
logging.debug(f"Found %i items with to_date {to_date}" % len(dsel))
photos_sets.append(set(dsel.keys()))

View File

@ -46,13 +46,6 @@ def test_db_version():
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():
import osxphotos
import collections

View File

@ -130,13 +130,6 @@ def test_db_version():
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():
import osxphotos
import collections

View File

@ -138,13 +138,6 @@ def test_db_version():
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():
import osxphotos
import collections

View File

@ -164,13 +164,6 @@ def test_db_version():
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():
import osxphotos
import collections

View File

@ -697,13 +697,18 @@ def test_export_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 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(
@ -721,6 +726,64 @@ def test_query_date():
json_got = json.loads(result.output)
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():
"""Test query --keyword """

View File

@ -29,13 +29,6 @@ def test_db_version():
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():
import osxphotos
import collections

View File

@ -46,13 +46,6 @@ def test_db_version():
# 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():
import osxphotos
import collections

View File

@ -47,13 +47,6 @@ def test_db_version():
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():
import osxphotos
import collections

View File

@ -1,4 +1,3 @@
import pytest
from osxphotos._constants import _UNKNOWN_PERSON
@ -82,13 +81,6 @@ def test_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():
import osxphotos
import collections
@ -272,6 +264,7 @@ def test_not_hidden():
def test_location_1():
# test photo with lat/lon info
import osxphotos
import pytest
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=["3Jn73XpSQQCluzRBMWRsMA"])