Skip to content

Commit a2da685

Browse files
ekulerEvidlo
andauthored
Use datetime functions from python standard lib (#371)
* remote setup.py, get version.py info from pyproject.toml * Use timezone class from stdlib * Parse date values using fromisoformat * Add timezone test for expiration date * Use date parse function for P3.6 compatibility * Update datetime format string WIP sad update format * remove dateutil dep * backwards compatible __version__ --------- Co-authored-by: evan <evan@evanw.org>
1 parent fc1771a commit a2da685

6 files changed

Lines changed: 83 additions & 84 deletions

File tree

pykeepass/baseelement.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import uuid
33
from lxml import etree
44
from lxml.builder import E
5-
from datetime import datetime
5+
from datetime import datetime, timezone
66

77

88
class BaseElement():
@@ -17,9 +17,9 @@ def __init__(self, element, kp=None, icon=None, expires=False,
1717
)
1818
if icon:
1919
self._element.append(E.IconID(icon))
20-
current_time_str = self._kp._encode_time(datetime.now())
20+
current_time_str = self._kp._encode_time(datetime.now(timezone.utc))
2121
if expiry_time:
22-
expiry_time_str = self._kp._encode_time(expiry_time)
22+
expiry_time_str = self._kp._encode_time(expiry_time.astimezone(timezone.utc))
2323
else:
2424
expiry_time_str = current_time_str
2525

@@ -116,8 +116,8 @@ def expires(self, value):
116116
def expired(self):
117117
if self.expires:
118118
return (
119-
self._kp._datetime_to_utc(datetime.utcnow()) >
120-
self._kp._datetime_to_utc(self.expiry_time)
119+
datetime.now(timezone.utc) >
120+
self.expiry_time
121121
)
122122

123123
return False
@@ -178,7 +178,7 @@ def touch(self, modify=False):
178178
Args:
179179
modify (bool): update access time as well a modification time
180180
"""
181-
now = datetime.now()
181+
now = datetime.now(timezone.utc)
182182
self.atime = now
183183
if modify:
184184
self.mtime = now

pykeepass/pykeepass.py

Lines changed: 14 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@
1010

1111
from binascii import Error as BinasciiError
1212
from construct import Container, ChecksumError, CheckError
13-
from dateutil import parser, tz
14-
from datetime import datetime, timedelta
13+
from datetime import datetime, timedelta, timezone
1514
from lxml import etree
1615
from lxml.builder import E
1716
from pathlib import Path
@@ -29,7 +28,7 @@
2928
BLANK_DATABASE_FILENAME = "blank_database.kdbx"
3029
BLANK_DATABASE_LOCATION = os.path.join(os.path.dirname(os.path.realpath(__file__)), BLANK_DATABASE_FILENAME)
3130
BLANK_DATABASE_PASSWORD = "password"
32-
31+
DT_ISOFORMAT = "%Y-%m-%dT%H:%M:%S%fZ"
3332

3433
class PyKeePass():
3534
"""Open a KeePass database
@@ -707,7 +706,7 @@ def password(self):
707706
@password.setter
708707
def password(self, password):
709708
self._password = password
710-
self.credchange_date = datetime.now()
709+
self.credchange_date = datetime.now(timezone.utc)
711710

712711
@property
713712
def keyfile(self):
@@ -717,7 +716,7 @@ def keyfile(self):
717716
@keyfile.setter
718717
def keyfile(self, keyfile):
719718
self._keyfile = keyfile
720-
self.credchange_date = datetime.now()
719+
self.credchange_date = datetime.now(timezone.utc)
721720

722721
@property
723722
def credchange_required_days(self):
@@ -754,16 +753,16 @@ def credchange_date(self):
754753

755754
@credchange_date.setter
756755
def credchange_date(self, date):
757-
time = self._xpath('/KeePassFile/Meta/MasterKeyChanged', first=True)
758-
time.text = self._encode_time(date)
756+
mk_time = self._xpath('/KeePassFile/Meta/MasterKeyChanged', first=True)
757+
mk_time.text = self._encode_time(date)
759758

760759
@property
761760
def credchange_required(self):
762761
"""bool: Check if credential change is required"""
763762
change_date = self.credchange_date
764763
if change_date is None or self.credchange_required_days == -1:
765764
return False
766-
now_date = self._datetime_to_utc(datetime.now())
765+
now_date = datetime.now(timezone.utc)
767766
return (now_date - change_date).days > self.credchange_required_days
768767

769768
@property
@@ -772,38 +771,31 @@ def credchange_recommended(self):
772771
change_date = self.credchange_date
773772
if change_date is None or self.credchange_recommended_days == -1:
774773
return False
775-
now_date = self._datetime_to_utc(datetime.now())
774+
now_date = datetime.now(timezone.utc)
776775
return (now_date - change_date).days > self.credchange_recommended_days
777776

778777
# ---------- Datetime Functions ----------
779778

780-
def _datetime_to_utc(self, dt):
781-
"""Convert naive datetimes to UTC"""
782-
783-
if not dt.tzinfo:
784-
dt = dt.replace(tzinfo=tz.gettz())
785-
return dt.astimezone(tz.gettz('UTC'))
786-
787779
def _encode_time(self, value):
788780
"""bytestring or plaintext string: Convert datetime to base64 or plaintext string"""
789781

790782
if self.version >= (4, 0):
791783
diff_seconds = int(
792784
(
793-
self._datetime_to_utc(value) -
785+
value -
794786
datetime(
795787
year=1,
796788
month=1,
797789
day=1,
798-
tzinfo=tz.gettz('UTC')
790+
tzinfo=timezone.utc
799791
)
800792
).total_seconds()
801793
)
802794
return base64.b64encode(
803795
struct.pack('<Q', diff_seconds)
804796
).decode('utf-8')
805797
else:
806-
return self._datetime_to_utc(value).isoformat()
798+
return value.strftime(DT_ISOFORMAT)
807799

808800
def _decode_time(self, text):
809801
"""datetime.datetime: Convert base64 time or plaintext time to datetime"""
@@ -812,21 +804,15 @@ def _decode_time(self, text):
812804
# decode KDBX4 date from b64 format
813805
try:
814806
return (
815-
datetime(year=1, month=1, day=1, tzinfo=tz.gettz('UTC')) +
807+
datetime(year=1, month=1, day=1, tzinfo=timezone.utc) +
816808
timedelta(
817809
seconds=struct.unpack('<Q', base64.b64decode(text))[0]
818810
)
819811
)
820812
except BinasciiError:
821-
return parser.parse(
822-
text,
823-
tzinfos={'UTC': tz.gettz('UTC')}
824-
)
813+
return datetime.strptime(text, DT_ISOFORMAT).replace(tzinfo=timezone.utc)
825814
else:
826-
return parser.parse(
827-
text,
828-
tzinfos={'UTC': tz.gettz('UTC')}
829-
)
815+
return datetime.strptime(text, DT_ISOFORMAT).replace(tzinfo=timezone.utc)
830816

831817
def create_database(
832818
filename, password=None, keyfile=None, transformed_key=None

pykeepass/version.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1-
__version__ = "4.0.6"
2-
31
__all__= ["__version__"]
2+
3+
# FIXME: switch to using importlib.metadata when dropping Python<=3.7
4+
import pkg_resources
5+
__version__ = pkg_resources.get_distribution('pykeepass').version

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
[project]
22
name = "pykeepass"
3+
version = "4.0.7"
34
readme = "README.rst"
45
description = "Python library to interact with keepass databases (supports KDBX3 and KDBX4)"
56
authors = [
@@ -9,7 +10,6 @@ authors = [
910
license = {text = "GPL-3.0"}
1011
keywords = ["vault", "keepass"]
1112
dependencies = [
12-
"python-dateutil>=2.7.0",
1313
"construct>=2.10.53",
1414
"argon2_cffi>=18.1.0",
1515
"pycryptodomex>=3.6.2",

setup.py

Lines changed: 0 additions & 30 deletions
This file was deleted.

tests/tests.py

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,8 @@
55
import shutil
66
import unittest
77
import uuid
8-
from datetime import datetime, timedelta
8+
from datetime import datetime, timedelta, timezone
99

10-
from dateutil import tz
1110
from pathlib import Path
1211

1312
from io import BytesIO
@@ -222,7 +221,7 @@ def test_history_group(self):
222221

223222
def test_add_delete_move_entry(self):
224223
unique_str = 'test_add_entry_'
225-
expiry_time = datetime.now()
224+
expiry_time = datetime.now(timezone.utc)
226225
entry = self.kp.add_entry(
227226
self.kp.root_group,
228227
unique_str + 'title',
@@ -246,8 +245,6 @@ def test_add_delete_move_entry(self):
246245
self.assertEqual(len(results.tags), 6)
247246
self.assertTrue(results.uuid != None)
248247
self.assertTrue(results.autotype_sequence is None)
249-
# convert naive datetime to utc
250-
expiry_time_utc = expiry_time.replace(tzinfo=tz.gettz()).astimezone(tz.gettz('UTC'))
251248
self.assertEqual(results.icon, icons.KEY)
252249

253250
sub_group = self.kp.add_group(self.kp.root_group, 'sub_group')
@@ -283,6 +280,52 @@ def test_raise_exception_entry(self):
283280
)
284281
self.assertRaises(Exception, entry)
285282

283+
# ---------- Timezone test -----------
284+
285+
def test_expiration_time_tz(self):
286+
# The expiration date is compared in UTC
287+
# setting expiration date with tz offset 6 hours should result in expired entry
288+
unique_str = 'test_exptime_tz_1_'
289+
expiry_time = datetime.now(timezone(offset=timedelta(hours=6))).replace(microsecond=0)
290+
self.kp.add_entry(
291+
self.kp.root_group,
292+
unique_str + 'title',
293+
unique_str + 'user',
294+
unique_str + 'pass',
295+
expiry_time=expiry_time
296+
)
297+
results = self.kp.find_entries_by_title(unique_str + 'title', first=True)
298+
self.assertEqual(results.expired, True)
299+
self.assertEqual(results.expiry_time, expiry_time.astimezone(timezone.utc))
300+
301+
# setting expiration date with UTC tz should result in expired entry
302+
unique_str = 'test_exptime_tz_2_'
303+
expiry_time = datetime.now(timezone.utc).replace(microsecond=0)
304+
self.kp.add_entry(
305+
self.kp.root_group,
306+
unique_str + 'title',
307+
unique_str + 'user',
308+
unique_str + 'pass',
309+
expiry_time=expiry_time
310+
)
311+
results = self.kp.find_entries_by_title(unique_str + 'title', first=True)
312+
self.assertEqual(results.expired, True)
313+
self.assertEqual(results.expiry_time, expiry_time.astimezone(timezone.utc))
314+
315+
# setting expiration date with tz offset -6 hours while adding 6 hours should result in valid entry
316+
unique_str = 'test_exptime_tz_3_'
317+
expiry_time = datetime.now(timezone(offset=timedelta(hours=-6))).replace(microsecond=0) + timedelta(hours=6)
318+
self.kp.add_entry(
319+
self.kp.root_group,
320+
unique_str + 'title',
321+
unique_str + 'user',
322+
unique_str + 'pass',
323+
expiry_time=expiry_time
324+
)
325+
results = self.kp.find_entries_by_title(unique_str + 'title', first=True)
326+
self.assertEqual(results.expired, False)
327+
self.assertEqual(results.expiry_time, expiry_time.astimezone(timezone.utc))
328+
286329
# ---------- Entries representation -----------
287330

288331
def test_print_entries(self):
@@ -433,7 +476,7 @@ def test_recyclebinemptying(self):
433476
class EntryTests3(KDBX3Tests):
434477

435478
def test_fields(self):
436-
time = datetime.now().replace(microsecond=0)
479+
expiry_time = datetime.now(timezone.utc).replace(microsecond=0)
437480
entry = Entry(
438481
'title',
439482
'username',
@@ -443,7 +486,7 @@ def test_fields(self):
443486
tags='tags',
444487
otp='otp',
445488
expires=True,
446-
expiry_time=time,
489+
expiry_time=expiry_time,
447490
icon=icons.KEY,
448491
kp=self.kp
449492
)
@@ -456,8 +499,7 @@ def test_fields(self):
456499
self.assertEqual(entry.tags, ['tags'])
457500
self.assertEqual(entry.otp, 'otp')
458501
self.assertEqual(entry.expires, True)
459-
self.assertEqual(entry.expiry_time,
460-
time.replace(tzinfo=tz.gettz()).astimezone(tz.gettz('UTC')))
502+
self.assertEqual(entry.expiry_time, expiry_time)
461503
self.assertEqual(entry.icon, icons.KEY)
462504
self.assertEqual(entry.is_a_history_entry, False)
463505
self.assertEqual(
@@ -487,7 +529,7 @@ def test_references(self):
487529
self.assertNotEqual(clone1, clone2)
488530

489531
def test_set_and_get_fields(self):
490-
time = datetime.now().replace(microsecond=0)
532+
time = datetime.now(timezone.utc).replace(microsecond=0)
491533
changed_time = time + timedelta(hours=9)
492534
changed_string = 'changed_'
493535
entry = Entry(
@@ -528,8 +570,7 @@ def test_set_and_get_fields(self):
528570
self.assertEqual(entry.get_custom_property('foo'), None)
529571
# test time properties
530572
self.assertEqual(entry.expires, False)
531-
self.assertEqual(entry.expiry_time,
532-
changed_time.replace(tzinfo=tz.gettz()).astimezone(tz.gettz('UTC')))
573+
self.assertEqual(entry.expiry_time, changed_time)
533574

534575
entry.tags = 'changed_tags'
535576
self.assertEqual(entry.tags, ['changed_tags'])
@@ -540,8 +581,8 @@ def test_set_and_get_fields(self):
540581

541582
def test_expired_datetime_offset(self):
542583
"""Test for https://github.com/pschmitt/pykeepass/issues/115"""
543-
future_time = datetime.now() + timedelta(days=1)
544-
past_time = datetime.now() - timedelta(days=1)
584+
future_time = datetime.now(timezone.utc) + timedelta(days=1)
585+
past_time = datetime.now(timezone.utc) - timedelta(days=1)
545586
entry = Entry(
546587
'title',
547588
'username',
@@ -695,7 +736,7 @@ def test_find_history_entries(self):
695736

696737
# change the active entries to test integrity of the history items
697738
backup = {}
698-
now = datetime.now()
739+
now = datetime.now(timezone.utc)
699740
for entry in res1:
700741
backup[entry.uuid] = {"atime": entry.atime, "mtime": entry.mtime, "ctime": entry.ctime}
701742
entry.title = changed + 'title'
@@ -863,8 +904,8 @@ def test_credchange(self):
863904

864905
required_days = 5
865906
recommended_days = 5
866-
unexpired_date = datetime.now() - timedelta(days=1)
867-
expired_date = datetime.now() - timedelta(days=10)
907+
unexpired_date = datetime.now(timezone.utc) - timedelta(days=1)
908+
expired_date = datetime.now(timezone.utc) - timedelta(days=10)
868909

869910
self.kp.credchange_required_days = required_days
870911
self.kp.credchange_recommended_days = recommended_days

0 commit comments

Comments
 (0)