Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support time zone information in the createDate attribute in the index #67

Draft
wants to merge 24 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1bcccbe
Add property Exif.gpsDateTime
RKrahl Dec 1, 2024
6f51973
Store the exifdata as an attribute in IdxItem
RKrahl Dec 23, 2024
433b249
Add method IdxItem.get_createDate_tzinfo()
RKrahl Dec 23, 2024
f6d2e34
When creating a new index item from an image file, derive the time
RKrahl Dec 23, 2024
27caff6
Round the offset for time zone objects to the minute
RKrahl Dec 23, 2024
f5335af
Add a datetools module
RKrahl Dec 24, 2024
3f9f7df
Fall back to using the local time zone when creating an index if
RKrahl Dec 24, 2024
874b07c
Add UTC offset to indication of create date in image info dialog
RKrahl Dec 24, 2024
36c7a32
Merge branch 'develop' into timezone2
RKrahl Dec 30, 2024
17436b8
Set the default time zone in the index attributes rather then passing
RKrahl Dec 30, 2024
be73d64
Aesthetic change: compile overly long lists of keyword args to Index()
RKrahl Dec 30, 2024
83b1fd8
Complete review of the datetools module
RKrahl Dec 31, 2024
9eb1a3c
Now requires python-dateutil
RKrahl Dec 31, 2024
f7e1e24
Add unit test for datetools module
RKrahl Dec 31, 2024
4241d51
Add a --timezone command line flag for the 'photoidx create' and
RKrahl Dec 31, 2024
30531fb
Also update the in-memory head of the index when building a new head
RKrahl Jan 1, 2025
d0836c7
Update tests
RKrahl Jan 1, 2025
54b5cd5
Fix test_05_unicode_tags.py
RKrahl Jan 1, 2025
8a3d2f9
Run the tests in test_02_filter_date.py and test_02_filter_date_args.py
RKrahl Jan 1, 2025
8d6201c
Fix IdxFilter to properly deal with an offset-aware index:
RKrahl Jan 1, 2025
663abc0
Fix Stats to properly deal with an offset-aware index
RKrahl Jan 1, 2025
919637d
Drop microseconds in the Date header in the index file
RKrahl Jan 1, 2025
9d9b21c
Make test data index-subdirs.yaml offest aware
RKrahl Jan 1, 2025
826875a
Update changelog
RKrahl Jan 1, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ distutils-pytest
git-props
pytest >= 3.6.0
pytest-dependency
python-dateutil
setuptools
7 changes: 7 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ New features

+ `#68`_, `#70`_: Extend the index file format to store attributes of
the index itself.
+ `#7`_, `#67`_: Support time zone information in the `createDate`
attribute in the index.
+ `#38`_, `#62`_: Add geo information to the output of the `stats`
subcommand in :ref:`photo-idx`.

Expand All @@ -21,15 +23,20 @@ Incompatible changes
file silently to the new format when writing it back. But the tools
from earlier versions will not be able to read the new format.

+ Change the argument list for :class:`~photoidx.idxfilter.IdxFilter`
and :class:`~photoidx.stats.Stats`: add the idx as new first argument.

Internal changes
----------------

+ `#66`_: Review build tool chain.
+ `#69`_: Refactor test suite.

.. _#7: https://github.com/RKrahl/photoidx/issues/7
.. _#38: https://github.com/RKrahl/photoidx/issues/38
.. _#62: https://github.com/RKrahl/photoidx/pull/62
.. _#66: https://github.com/RKrahl/photoidx/pull/66
.. _#67: https://github.com/RKrahl/photoidx/issues/67
.. _#68: https://github.com/RKrahl/photoidx/issues/68
.. _#69: https://github.com/RKrahl/photoidx/pull/69
.. _#70: https://github.com/RKrahl/photoidx/pull/70
Expand Down
6 changes: 6 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ Required library packages:

+ `ExifRead`_ >= 2.2.0

+ `python-dateutil`_

If this package is not available, the core functions might still
work, but some features will be degraded, spurious errors may occur.

+ `PySide2`_

Optional library packages:
Expand Down Expand Up @@ -147,6 +152,7 @@ permissions and limitations under the License.
.. _packaging: https://github.com/pypa/packaging/
.. _PyYAML: https://github.com/yaml/pyyaml
.. _ExifRead: https://github.com/ianare/exif-py
.. _python-dateutil: https://dateutil.readthedocs.io/en/stable/
.. _PySide2: https://www.pyside.org/
.. _vignette: https://github.com/hydrargyrum/vignette
.. _Pillow: https://python-pillow.org/
Expand Down
2 changes: 2 additions & 0 deletions python-photoidx.spec
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ BuildRequires: python3-distutils-pytest
BuildRequires: python3-pytest-dependency
BuildRequires: python3-PyYAML
BuildRequires: python3-ExifRead >= 2.2.0
BuildRequires: python3-python-dateutil
%endif
Provides: python3-photo = %{version}-%{release}
Obsoletes: python3-photo < %{version}-%{release}
Requires: python3-PyYAML
Requires: python3-ExifRead >= 2.2.0
Requires: python3-python-dateutil
BuildArch: noarch
BuildRoot: %{_tmppath}/%{name}-%{version}-build

Expand Down
2 changes: 1 addition & 1 deletion scripts/imageview.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,6 @@
else:
readOnly = not args.create
dirty = args.create
idxfilter = photoidx.idxfilter.IdxFilter.from_args(args)
idxfilter = photoidx.idxfilter.IdxFilter.from_args(idx, args)
imageViewer = ImageViewer(idx, idxfilter, args.scale, readOnly, dirty)
sys.exit(app.exec_())
56 changes: 37 additions & 19 deletions scripts/photo-idx.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,38 @@
#! /usr/bin/python

import argparse
from photoidx.datetools import gettz
import photoidx.index
import photoidx.idxfilter
from photoidx.stats import Stats


def create(args):
checksums = args.checksums.split(',') if args.checksums else []
with photoidx.index.Index(idxfile=None, imgdir=args.directory,
checksums=checksums, comment=args.comment) as idx:
kwargs = dict(
idxfile = None,
imgdir = args.directory,
checksums = args.checksums.split(',') if args.checksums else [],
comment = args.comment,
default_tz = args.timezone,
)
with photoidx.index.Index(**kwargs) as idx:
idx.write()

def update(args):
with photoidx.index.Index(idxfile=args.directory, imgdir=args.directory,
checksums=None, comment=args.comment) as idx:
kwargs = dict(
idxfile = args.directory,
imgdir = args.directory,
checksums = None,
comment = args.comment,
default_tz = args.timezone,
)
with photoidx.index.Index(**kwargs) as idx:
idx.write()

def ls(args):
with photoidx.index.Index(idxfile=args.directory) as idx:
idxfilter = photoidx.idxfilter.IdxFilter.from_args(args)
for i in idxfilter.filter(idx):
idxfilter = photoidx.idxfilter.IdxFilter.from_args(idx, args)
for i in idxfilter.filter():
if args.checksum:
try:
checksum = i.checksum[args.checksum]
Expand All @@ -32,45 +44,45 @@ def ls(args):

def lstags(args):
with photoidx.index.Index(idxfile=args.directory) as idx:
idxfilter = photoidx.idxfilter.IdxFilter.from_args(args)
idxfilter = photoidx.idxfilter.IdxFilter.from_args(idx, args)
tags = set()
for i in idxfilter.filter(idx):
for i in idxfilter.filter():
tags.update(i.tags)
for t in sorted(tags):
print(t)

def addtag(args):
with photoidx.index.Index(idxfile=args.directory) as idx:
idxfilter = photoidx.idxfilter.IdxFilter.from_args(args)
for i in idxfilter.filter(idx):
idxfilter = photoidx.idxfilter.IdxFilter.from_args(idx, args)
for i in idxfilter.filter():
i.tags.add(args.tag)
idx.write()

def rmtag(args):
with photoidx.index.Index(idxfile=args.directory) as idx:
idxfilter = photoidx.idxfilter.IdxFilter.from_args(args)
for i in idxfilter.filter(idx):
idxfilter = photoidx.idxfilter.IdxFilter.from_args(idx, args)
for i in idxfilter.filter():
i.tags.discard(args.tag)
idx.write()

def select(args):
with photoidx.index.Index(idxfile=args.directory) as idx:
idxfilter = photoidx.idxfilter.IdxFilter.from_args(args)
for i in idxfilter.filter(idx):
idxfilter = photoidx.idxfilter.IdxFilter.from_args(idx, args)
for i in idxfilter.filter():
i.selected = True
idx.write()

def deselect(args):
with photoidx.index.Index(idxfile=args.directory) as idx:
idxfilter = photoidx.idxfilter.IdxFilter.from_args(args)
for i in idxfilter.filter(idx):
idxfilter = photoidx.idxfilter.IdxFilter.from_args(idx, args)
for i in idxfilter.filter():
i.selected = False
idx.write()

def stats(args):
with photoidx.index.Index(idxfile=args.directory) as idx:
idxfilter = photoidx.idxfilter.IdxFilter.from_args(args)
stats = Stats(idxfilter.filter(idx))
idxfilter = photoidx.idxfilter.IdxFilter.from_args(idx, args)
stats = Stats(idx, idxfilter.filter())
print(str(stats))


Expand All @@ -81,6 +93,9 @@ def stats(args):

create_parser = subparsers.add_parser('create', help="create the index")
create_parser.add_argument('--comment', help="Comment text")
create_parser.add_argument('--timezone',
help="Time zone to set for image create time",
type=gettz, default=gettz())
create_parser.add_argument('--checksums', default="md5",
help=("comma separated list of "
"hash algorithms to calculate checksums"))
Expand All @@ -89,6 +104,9 @@ def stats(args):
update_parser = subparsers.add_parser('update',
help="add images to an existing index")
update_parser.add_argument('--comment', help="Comment text")
update_parser.add_argument('--timezone',
help="Time zone to set for image create time",
type=gettz, default=gettz())
update_parser.set_defaults(func=update)

ls_parser = subparsers.add_parser('ls', help="list image files")
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ def run(self):
python_requires = ">=3.6",
install_requires = [
"setuptools", "packaging",
"PyYAML >=5.4", "ExifRead >=2.2.0", "PySide2",
"PyYAML >=5.4", "ExifRead >=2.2.0", "python-dateutil", "PySide2",
],
cmdclass = dict(cmdclass, build_py=build_py, sdist=sdist, meta=meta),
)
85 changes: 85 additions & 0 deletions src/photoidx/datetools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Helper functions for manipulating dates and times.

**Note**: This module might be useful independently of photoidx. It
is included here because photoidx uses it internally, but it is not
considered to be part of the API. Changes in this module are not
considered API changes of photoidx. It may even be removed from
future versions of the photoidx distribution without further notice.
"""

import datetime
from pathlib import Path
import re
try:
import dateutil.tz
_have_dateutil_tz = True
except ImportError:
_have_dateutil_tz = False

_tz_offs_re = re.compile(r'''
(?:UTC)?
(?P<sign>[+-])
(?P<hour>\d{2})
:?
(?P<min>\d{2})
''', re.X)
_tzfile_repr_re = re.compile(r'\w+\(\'(?P<path>[/a-zA-Z0-9_]+)\'\)')


def get_rounded_timezone(offs):
"""Create a timezone object from an offset rounded to whole minutes.
"""
minutes = round(offs.total_seconds()/60)
return datetime.timezone(datetime.timedelta(minutes=minutes))


def get_local_offset_time_zone():
"""Get a time zone object corresponding to the local time.
"""
now = datetime.datetime.now()
# We'd need utcnow() here, but it is deprecated and will be
# removed from future Python versions.
utcnow = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
return get_rounded_timezone(now - utcnow)


def gettz(name=None):
"""Get a time zone from a string representation.
"""
if name is not None:
if not name:
return None
m = _tz_offs_re.fullmatch(name)
if m:
hour, minute = m.group('hour', 'min')
offs = datetime.timedelta(hours=int(hour), minutes=int(minute))
if m.group('sign') == '-':
offs = -offs
return datetime.timezone(offs)
if _have_dateutil_tz:
tz = dateutil.tz.gettz(name)
if tz:
return tz
elif name is None:
return get_local_offset_time_zone()
raise ValueError("invalid time zone '%s'" % name)


def gettz_name(tz):
"""Get the name from a dateutil.tz.tzfile object.

This is supposed to be the inverse of gettz().
"""
if _have_dateutil_tz and isinstance(tz, dateutil.tz.tzfile):
# Note: this code probably does not work on Windows
m = _tzfile_repr_re.match(repr(tz))
if m:
path = Path(m.group('path')).resolve()
for zi_dir in dateutil.tz.TZPATHS:
try:
return str(path.relative_to(zi_dir))
except ValueError:
continue
elif isinstance(tz, datetime.timezone):
return str(tz)
raise ValueError("invalid time zone object %r" % tz)
13 changes: 13 additions & 0 deletions src/photoidx/exif.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,19 @@ def gpsPosition(self):
lon = lon_tuple[0] + lon_tuple[1]/60 + lon_tuple[2]/3600
return { latref:float(lat), lonref:float(lon) }

@property
def gpsDateTime(self):
"""GPS Date/Time."""
try:
d = (int(v) for v in self._tags['GPS GPSDate'].values.split(':'))
t = (int(v) for v in self._tags['GPS GPSTimeStamp'].values)
except (AttributeError, KeyError):
return None
else:
d = datetime.date(*d)
t = datetime.time(*t)
return datetime.datetime.combine(d, t)

@property
def cameraModel(self):
"""Camera Model."""
Expand Down
21 changes: 15 additions & 6 deletions src/photoidx/idxfilter.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,28 @@ def _dt(args):
enddate = _dt(match.group('y1', 'm1', 'd1', 'h1', 'min1', 's1'))
return (startdate, enddate)

def _set_date_timezone(tz, datetimes):
"""Replace the time zone in a tuple of datetime objects.
"""
if datetimes:
return tuple(dt.replace(tzinfo=tz) for dt in datetimes)
else:
return None


class IdxFilter(object):

@classmethod
def from_args(cls, args):
def from_args(cls, idx, args):
kwargs = {}
for a in ("tags", "select", "date", "gpspos", "gpsradius", "files"):
kwargs[a] = getattr(args, a)
return cls(**kwargs)
return cls(idx, **kwargs)

def __init__(self,
def __init__(self, idx,
tags=None, select=None, date=None,
gpspos=None, gpsradius=3.0, files=None):
self.idx = idx
if tags is not None:
self.taglist = set()
self.negtaglist = set()
Expand All @@ -65,7 +74,7 @@ def __init__(self,
self.taglist = None
self.negtaglist = None
self.select = select
self.date = date
self.date = _set_date_timezone(self.idx.timeZone, date)
if gpspos:
self.gpspos = gpspos
self.gpsradius = gpsradius
Expand Down Expand Up @@ -98,8 +107,8 @@ def __call__(self, item):
return False
return True

def filter(self, idx):
return filter(self, idx)
def filter(self):
return filter(self, self.idx)


def addFilterArguments(argparser):
Expand Down
Loading
Loading