From e9f4498f5169a6738f600e46da94c037e360fba1 Mon Sep 17 00:00:00 2001 From: Jasper Spaans Date: Mon, 15 Sep 2025 10:23:24 +0200 Subject: [PATCH 1/7] Add option to return the timecode that matched when verifying TOTP. This adds a boolean option `return_timecode` to TOTP.verify that changes its return value on success to an integer containing the timecode that was used to generate the matching OTP. This is especially useful when `valid_window` is not 0, as then several values might be valid at the same moment. If an implementation compares just the bare OTP, replaying a recent value is possible. By comparing timecodes, that can be prevented, as they are strictly increasing. The patch also fixes a minor typing issue, as the `for_time` argument can also be an number - there even is a test that calls it like this. Finally, the special case for `valid_window == 0` has been removed as it is a micro-optimization that only leads to repeated code. --- src/pyotp/totp.py | 46 +++++++++++++++++++++++++++++++++++++--------- test.py | 13 +++++++++++++ 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src/pyotp/totp.py b/src/pyotp/totp.py index 9908d55..d7bf596 100644 --- a/src/pyotp/totp.py +++ b/src/pyotp/totp.py @@ -2,7 +2,7 @@ import datetime import hashlib import time -from typing import Any, Optional, Union +from typing import Any, Literal, Optional, Union, overload from . import utils from .otp import OTP @@ -65,25 +65,53 @@ def now(self) -> str: """ return self.generate_otp(self.timecode(datetime.datetime.now())) - def verify(self, otp: str, for_time: Optional[datetime.datetime] = None, valid_window: int = 0) -> bool: + @overload + def verify( + self, + otp: str, + for_time: Optional[Union[datetime.datetime, int]], + valid_window: int, + return_timecode: Literal[False], + ) -> bool: ... + + @overload + def verify( + self, + otp: str, + for_time: Optional[Union[datetime.datetime, int]], + valid_window: int, + return_timecode: Literal[True], + ) -> Literal[False] | int: ... + + def verify( + self, + otp: str, + for_time: Optional[Union[datetime.datetime, int]] = None, + valid_window: int = 0, + return_timecode: bool = False, + ) -> bool | int: """ Verifies the OTP passed in against the current time OTP. :param otp: the OTP to check against :param for_time: Time to check OTP at (defaults to now) :param valid_window: extends the validity to this many counter ticks before and after the current one - :returns: True if verification succeeded, False otherwise + :param return_timecode: if True, on success return the timecode of the OTP (to be used to prevent replay attacks) + :returns: True or the matching timecode if verification succeeded (depending on return_timecode), False otherwise """ if for_time is None: for_time = datetime.datetime.now() + elif not isinstance(for_time, datetime.datetime): + for_time = datetime.datetime.fromtimestamp(int(for_time)) - if valid_window: - for i in range(-valid_window, valid_window + 1): - if utils.strings_equal(str(otp), str(self.at(for_time, i))): + base_timecode = self.timecode(for_time) + for i in range(-valid_window, valid_window + 1): + if utils.strings_equal(str(otp), str(self.generate_otp(base_timecode + i))): + if return_timecode: + return base_timecode + i + else: return True - return False - - return utils.strings_equal(str(otp), str(self.at(for_time))) + return False def provisioning_uri(self, name: Optional[str] = None, issuer_name: Optional[str] = None, **kwargs) -> str: """ diff --git a/test.py b/test.py index 2d33c60..c94af62 100755 --- a/test.py +++ b/test.py @@ -173,6 +173,19 @@ def test_validate_totp(self): with Timecop(1297553958 + 30): self.assertFalse(totp.verify("102705")) + def test_return_timecode_on_verify(self): + totp = pyotp.TOTP("wrn3pqx5uqxqvnqr") + with Timecop(1297553958): + timecode1 = totp.verify("102705", valid_window=1, return_timecode=True) + self.assertTrue(isinstance(timecode1, int)) + with Timecop(1297553958 + 30): + timecode2 = totp.verify("102705", valid_window=1, return_timecode=True) + self.assertEqual(timecode1, timecode2) + + with Timecop(1297553958 + 60): + timecode3 = totp.verify("102705", valid_window=1, return_timecode=True) + self.assertFalse(timecode3) + def test_input_before_epoch(self): totp = pyotp.TOTP("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ") # -1 and -29.5 round down to 0 (epoch) From fb49577cff0fe338dbfc7348bf4a61c8f983548c Mon Sep 17 00:00:00 2001 From: Jasper Spaans Date: Mon, 15 Sep 2025 13:17:47 +0200 Subject: [PATCH 2/7] typing: accept float for timestamp values Tests were already passing -29.5 to `TOTP.at`; note that casting a float to an int in Python just strips off the fractional part (like `floor(3)`). Because TOTP intervals are integers, this means that this rounding off operation does not change which interval the timestamp is in so we can do this safely. --- src/pyotp/totp.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/pyotp/totp.py b/src/pyotp/totp.py index d7bf596..4b0aad4 100644 --- a/src/pyotp/totp.py +++ b/src/pyotp/totp.py @@ -38,7 +38,7 @@ def __init__( self.interval = interval super().__init__(s=s, digits=digits, digest=digest, name=name, issuer=issuer) - def at(self, for_time: Union[int, datetime.datetime], counter_offset: int = 0) -> str: + def at(self, for_time: Union[float, datetime.datetime], counter_offset: int = 0) -> str: """ Accepts either a Unix timestamp integer or a datetime object. @@ -69,24 +69,24 @@ def now(self) -> str: def verify( self, otp: str, - for_time: Optional[Union[datetime.datetime, int]], - valid_window: int, - return_timecode: Literal[False], + for_time: Optional[Union[datetime.datetime, float]] = None, + valid_window: int = 0, + return_timecode: Literal[False] = False, ) -> bool: ... @overload def verify( self, otp: str, - for_time: Optional[Union[datetime.datetime, int]], - valid_window: int, - return_timecode: Literal[True], + for_time: Optional[Union[datetime.datetime, float]] = None, + valid_window: int = 0, + return_timecode: Literal[True] = True, ) -> Literal[False] | int: ... def verify( self, otp: str, - for_time: Optional[Union[datetime.datetime, int]] = None, + for_time: Optional[Union[datetime.datetime, float]] = None, valid_window: int = 0, return_timecode: bool = False, ) -> bool | int: From 14c89454c41c998ca3df36e208875a7397a9fd01 Mon Sep 17 00:00:00 2001 From: Jasper Spaans Date: Mon, 15 Sep 2025 13:39:36 +0200 Subject: [PATCH 3/7] typing: add explicit isinstance asserts to tests This allows mypy to infer that the called methods actually exist. Note that two self.assertIsInstance got replaced by these; the tests will still fail on type mismatch. --- test.py | 50 ++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/test.py b/test.py index c94af62..ead2165 100755 --- a/test.py +++ b/test.py @@ -48,7 +48,9 @@ def test_provisioning_uri(self): self.assertEqual(url.netloc, "hotp") self.assertEqual(url.path, "/mark%40percival") self.assertEqual(dict(parse_qsl(url.query)), {"secret": "wrn3pqx5uqxqvnqr", "counter": "0"}) - self.assertEqual(hotp.provisioning_uri(), pyotp.parse_uri(hotp.provisioning_uri()).provisioning_uri()) + parsed_otp = pyotp.parse_uri(hotp.provisioning_uri()) + assert isinstance(parsed_otp, pyotp.HOTP) + self.assertEqual(hotp.provisioning_uri(), parsed_otp.provisioning_uri()) hotp = pyotp.HOTP("wrn3pqx5uqxqvnqr", name="mark@percival", initial_count=12) url = urlparse(hotp.provisioning_uri()) @@ -56,7 +58,9 @@ def test_provisioning_uri(self): self.assertEqual(url.netloc, "hotp") self.assertEqual(url.path, "/mark%40percival") self.assertEqual(dict(parse_qsl(url.query)), {"secret": "wrn3pqx5uqxqvnqr", "counter": "12"}) - self.assertEqual(hotp.provisioning_uri(), pyotp.parse_uri(hotp.provisioning_uri()).provisioning_uri()) + parsed_otp = pyotp.parse_uri(hotp.provisioning_uri()) + assert isinstance(parsed_otp, pyotp.HOTP) + self.assertEqual(hotp.provisioning_uri(), parsed_otp.provisioning_uri()) hotp = pyotp.HOTP("wrn3pqx5uqxqvnqr", name="mark@percival", issuer="FooCorp!") url = urlparse(hotp.provisioning_uri()) @@ -66,7 +70,9 @@ def test_provisioning_uri(self): self.assertEqual( dict(parse_qsl(url.query)), {"secret": "wrn3pqx5uqxqvnqr", "counter": "0", "issuer": "FooCorp!"} ) - self.assertEqual(hotp.provisioning_uri(), pyotp.parse_uri(hotp.provisioning_uri()).provisioning_uri()) + parsed_otp = pyotp.parse_uri(hotp.provisioning_uri()) + assert isinstance(parsed_otp, pyotp.HOTP) + self.assertEqual(hotp.provisioning_uri(), parsed_otp.provisioning_uri()) key = "c7uxuqhgflpw7oruedmglbrk7u6242vb" hotp = pyotp.HOTP(key, digits=8, digest=hashlib.sha256, name="baco@peperina", issuer="FooCorp") @@ -84,7 +90,9 @@ def test_provisioning_uri(self): "algorithm": "SHA256", }, ) - self.assertEqual(hotp.provisioning_uri(), pyotp.parse_uri(hotp.provisioning_uri()).provisioning_uri()) + parsed_otp = pyotp.parse_uri(hotp.provisioning_uri()) + assert isinstance(parsed_otp, pyotp.HOTP) + self.assertEqual(hotp.provisioning_uri(), parsed_otp.provisioning_uri()) hotp = pyotp.HOTP(key, digits=8, name="baco@peperina", issuer="Foo Corp", initial_count=10) url = urlparse(hotp.provisioning_uri()) @@ -95,7 +103,9 @@ def test_provisioning_uri(self): dict(parse_qsl(url.query)), {"secret": "c7uxuqhgflpw7oruedmglbrk7u6242vb", "counter": "10", "issuer": "Foo Corp", "digits": "8"}, ) - self.assertEqual(hotp.provisioning_uri(), pyotp.parse_uri(hotp.provisioning_uri()).provisioning_uri()) + parsed_otp = pyotp.parse_uri(hotp.provisioning_uri()) + assert isinstance(parsed_otp, pyotp.HOTP) + self.assertEqual(hotp.provisioning_uri(), parsed_otp.provisioning_uri()) code = pyotp.totp.TOTP("S46SQCPPTCNPROMHWYBDCTBZXV") self.assertEqual(code.provisioning_uri(), "otpauth://totp/Secret?secret=S46SQCPPTCNPROMHWYBDCTBZXV") @@ -208,7 +218,9 @@ def test_provisioning_uri(self): self.assertEqual(url.netloc, "totp") self.assertEqual(url.path, "/mark%40percival") self.assertEqual(dict(parse_qsl(url.query)), {"secret": "wrn3pqx5uqxqvnqr"}) - self.assertEqual(totp.provisioning_uri(), pyotp.parse_uri(totp.provisioning_uri()).provisioning_uri()) + parsed_otp = pyotp.parse_uri(totp.provisioning_uri()) + assert isinstance(parsed_otp, pyotp.TOTP) + self.assertEqual(totp.provisioning_uri(), parsed_otp.provisioning_uri()) totp = pyotp.TOTP("wrn3pqx5uqxqvnqr", name="mark@percival", issuer="FooCorp!") url = urlparse(totp.provisioning_uri()) @@ -216,7 +228,9 @@ def test_provisioning_uri(self): self.assertEqual(url.netloc, "totp") self.assertEqual(url.path, "/FooCorp%21:mark%40percival") self.assertEqual(dict(parse_qsl(url.query)), {"secret": "wrn3pqx5uqxqvnqr", "issuer": "FooCorp!"}) - self.assertEqual(totp.provisioning_uri(), pyotp.parse_uri(totp.provisioning_uri()).provisioning_uri()) + parsed_otp = pyotp.parse_uri(totp.provisioning_uri()) + assert isinstance(parsed_otp, pyotp.TOTP) + self.assertEqual(totp.provisioning_uri(), parsed_otp.provisioning_uri()) key = "c7uxuqhgflpw7oruedmglbrk7u6242vb" totp = pyotp.TOTP(key, digits=8, interval=60, digest=hashlib.sha256, name="baco@peperina", issuer="FooCorp") @@ -234,7 +248,9 @@ def test_provisioning_uri(self): "algorithm": "SHA256", }, ) - self.assertEqual(totp.provisioning_uri(), pyotp.parse_uri(totp.provisioning_uri()).provisioning_uri()) + parsed_otp = pyotp.parse_uri(totp.provisioning_uri()) + assert isinstance(parsed_otp, pyotp.TOTP) + self.assertEqual(totp.provisioning_uri(), parsed_otp.provisioning_uri()) totp = pyotp.TOTP(key, digits=8, interval=60, name="baco@peperina", issuer="FooCorp") url = urlparse(totp.provisioning_uri()) @@ -245,7 +261,9 @@ def test_provisioning_uri(self): dict(parse_qsl(url.query)), {"secret": "c7uxuqhgflpw7oruedmglbrk7u6242vb", "issuer": "FooCorp", "digits": "8", "period": "60"}, ) - self.assertEqual(totp.provisioning_uri(), pyotp.parse_uri(totp.provisioning_uri()).provisioning_uri()) + parsed_otp = pyotp.parse_uri(totp.provisioning_uri()) + assert isinstance(parsed_otp, pyotp.TOTP) + self.assertEqual(totp.provisioning_uri(), parsed_otp.provisioning_uri()) totp = pyotp.TOTP(key, digits=8, name="baco@peperina", issuer="FooCorp") url = urlparse(totp.provisioning_uri()) @@ -256,7 +274,9 @@ def test_provisioning_uri(self): dict(parse_qsl(url.query)), {"secret": "c7uxuqhgflpw7oruedmglbrk7u6242vb", "issuer": "FooCorp", "digits": "8"}, ) - self.assertEqual(totp.provisioning_uri(), pyotp.parse_uri(totp.provisioning_uri()).provisioning_uri()) + parsed_otp = pyotp.parse_uri(totp.provisioning_uri()) + assert isinstance(parsed_otp, pyotp.TOTP) + self.assertEqual(totp.provisioning_uri(), parsed_otp.provisioning_uri()) def test_random_key_generation(self): self.assertEqual(len(pyotp.random_base32()), 32) @@ -400,6 +420,7 @@ def test_parse_steam(self): @unittest.skipIf(sys.version_info < (3, 6), "Skipping test that requires deterministic dict key enumeration") def test_algorithms(self): otp = pyotp.parse_uri("otpauth://totp?algorithm=SHA1&secret=GEZDGNBV&algorithm=SHA1") + assert isinstance(otp, pyotp.TOTP) self.assertEqual(hashlib.sha1, otp.digest) self.assertEqual(otp.at(0), "734055") self.assertEqual(otp.at(30), "662488") @@ -408,6 +429,7 @@ def test_algorithms(self): self.assertEqual(otp.provisioning_uri(name="n", issuer_name="i"), "otpauth://totp/i:n?secret=GEZDGNBV&issuer=i") otp = pyotp.parse_uri("otpauth://totp?algorithm=SHA1&secret=GEZDGNBV&algorithm=SHA1&period=60") + assert isinstance(otp, pyotp.TOTP) self.assertEqual(hashlib.sha1, otp.digest) self.assertEqual(otp.at(30), "734055") self.assertEqual(otp.at(60), "662488") @@ -416,6 +438,7 @@ def test_algorithms(self): ) otp = pyotp.parse_uri("otpauth://hotp?algorithm=SHA1&secret=GEZDGNBV&algorithm=SHA1") + assert isinstance(otp, pyotp.HOTP) self.assertEqual(hashlib.sha1, otp.digest) self.assertEqual(otp.at(0), "734055") self.assertEqual(otp.at(1), "662488") @@ -425,6 +448,7 @@ def test_algorithms(self): ) otp = pyotp.parse_uri("otpauth://hotp?algorithm=SHA1&secret=GEZDGNBV&algorithm=SHA1&counter=1") + assert isinstance(otp, pyotp.HOTP) self.assertEqual(hashlib.sha1, otp.digest) self.assertEqual(otp.at(0), "662488") self.assertEqual(otp.at(1), "289363") @@ -433,11 +457,13 @@ def test_algorithms(self): ) otp = pyotp.parse_uri("otpauth://totp?algorithm=SHA1&secret=GEZDGNBV&algorithm=SHA256") + assert isinstance(otp, pyotp.TOTP) self.assertEqual(hashlib.sha256, otp.digest) self.assertEqual(otp.at(0), "918961") self.assertEqual(otp.at(9000), "934470") otp = pyotp.parse_uri("otpauth://totp?algorithm=SHA1&secret=GEZDGNBV&algorithm=SHA512") + assert isinstance(otp, pyotp.TOTP) self.assertEqual(hashlib.sha512, otp.digest) self.assertEqual(otp.at(0), "816660") self.assertEqual(otp.at(9000), "524153") @@ -453,7 +479,7 @@ def test_algorithms(self): self.assertEqual(hashlib.sha512, otp.digest) otp = pyotp.parse_uri("otpauth://totp/Steam:?secret=FMXNK4QEGKVPULRTADY6JIDK5VHUBGZW&encoder=steam") - self.assertEqual(type(otp), pyotp.contrib.Steam) + assert isinstance(otp, pyotp.contrib.Steam) self.assertEqual(otp.at(0), "C5V56") self.assertEqual(otp.at(30), "QJY8Y") self.assertEqual(otp.at(60), "R3WQY") @@ -463,7 +489,7 @@ def test_algorithms(self): otp = pyotp.parse_uri( "otpauth://totp/Steam:?secret=FMXNK4QEGKVPULRTADY6JIDK5VHUBGZW&period=15&digits=7&encoder=steam" ) - self.assertEqual(type(otp), pyotp.contrib.Steam) + assert isinstance(otp, pyotp.contrib.Steam) self.assertEqual(otp.at(0), "C5V56") self.assertEqual(otp.at(30), "QJY8Y") self.assertEqual(otp.at(60), "R3WQY") From 9ff08b8db5d9fe53c526c1024d5ea94c4969deb6 Mon Sep 17 00:00:00 2001 From: Jasper Spaans Date: Mon, 15 Sep 2025 13:48:03 +0200 Subject: [PATCH 4/7] typing: don't construct TOTP instances with a secret that is bytes This accidentally worked but the typing police does not like it. --- src/pyotp/contrib/steam.py | 7 ++++++- test.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/pyotp/contrib/steam.py b/src/pyotp/contrib/steam.py index 1cd2bdd..87c608b 100644 --- a/src/pyotp/contrib/steam.py +++ b/src/pyotp/contrib/steam.py @@ -13,7 +13,12 @@ class Steam(TOTP): """ def __init__( - self, s: str, name: Optional[str] = None, issuer: Optional[str] = None, interval: int = 30, digits: int = 5 + self, + s: str, + name: Optional[str] = None, + issuer: Optional[str] = None, + interval: int = 30, + digits: int = 5, ) -> None: """ :param s: secret in base32 format diff --git a/test.py b/test.py index ead2165..4d02acb 100755 --- a/test.py +++ b/test.py @@ -151,7 +151,7 @@ class TOTPExampleValuesFromTheRFC(unittest.TestCase): def test_match_rfc(self): for digest, secret in self.RFC_VALUES: - totp = pyotp.TOTP(base64.b32encode(secret), 8, digest) + totp = pyotp.TOTP(base64.b32encode(secret).decode(), 8, digest) for utime, code in self.RFC_VALUES[(digest, secret)]: if utime > sys.maxsize: warn( From 0a964ba156f250e4203fcb9a0de6d02a6bbac810 Mon Sep 17 00:00:00 2001 From: Jasper Spaans Date: Mon, 15 Sep 2025 13:50:39 +0200 Subject: [PATCH 5/7] tests: make the Timecop more pythonic --- test.py | 66 +++++++++++++++++++++++---------------------------------- 1 file changed, 27 insertions(+), 39 deletions(-) diff --git a/test.py b/test.py index 4d02acb..42885ae 100755 --- a/test.py +++ b/test.py @@ -1,11 +1,13 @@ #!/usr/bin/env python import base64 +import contextlib import datetime import hashlib import os import sys -import unittest +import typing +import unittest.mock from urllib.parse import parse_qsl, urlparse from warnings import warn @@ -172,27 +174,27 @@ def test_match_rfc_digit_length(self): def test_match_google_authenticator_output(self): totp = pyotp.TOTP("wrn3pqx5uqxqvnqr") - with Timecop(1297553958): + with timecop(1297553958): self.assertEqual(totp.now(), "102705") def test_validate_totp(self): totp = pyotp.TOTP("wrn3pqx5uqxqvnqr") - with Timecop(1297553958): + with timecop(1297553958): self.assertTrue(totp.verify("102705")) self.assertTrue(totp.verify("102705")) - with Timecop(1297553958 + 30): + with timecop(1297553958 + 30): self.assertFalse(totp.verify("102705")) def test_return_timecode_on_verify(self): totp = pyotp.TOTP("wrn3pqx5uqxqvnqr") - with Timecop(1297553958): + with timecop(1297553958): timecode1 = totp.verify("102705", valid_window=1, return_timecode=True) self.assertTrue(isinstance(timecode1, int)) - with Timecop(1297553958 + 30): + with timecop(1297553958 + 30): timecode2 = totp.verify("102705", valid_window=1, return_timecode=True) self.assertEqual(timecode1, timecode2) - with Timecop(1297553958 + 60): + with timecop(1297553958 + 60): timecode3 = totp.verify("102705", valid_window=1, return_timecode=True) self.assertFalse(timecode3) @@ -206,9 +208,9 @@ def test_input_before_epoch(self): def test_validate_totp_with_digit_length(self): totp = pyotp.TOTP("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ") - with Timecop(1111111111): + with timecop(1111111111): self.assertTrue(totp.verify("050471")) - with Timecop(1297553958 + 30): + with timecop(1297553958 + 30): self.assertFalse(totp.verify("050471")) def test_provisioning_uri(self): @@ -307,25 +309,25 @@ def test_match_examples(self): def test_verify(self): steam = pyotp.contrib.Steam("BASE32SECRET3232") - with Timecop(1662883100): + with timecop(1662883100): self.assertTrue(steam.verify("N3G63")) - with Timecop(1662883100 + 30): + with timecop(1662883100 + 30): self.assertFalse(steam.verify("N3G63")) - with Timecop(946681223): + with timecop(946681223): self.assertTrue(steam.verify("7VP3X")) - with Timecop(946681223 + 30): + with timecop(946681223 + 30): self.assertFalse(steam.verify("7VP3X")) steam = pyotp.contrib.Steam("FMXNK4QEGKVPULRTADY6JIDK5VHUBGZW") - with Timecop(1662884261): + with timecop(1662884261): self.assertTrue(steam.verify("V6WKJ")) - with Timecop(1662884261 + 30): + with timecop(1662884261 + 30): self.assertFalse(steam.verify("V6WKJ")) - with Timecop(946681223): + with timecop(946681223): self.assertTrue(steam.verify("4MK54")) - with Timecop(946681223 + 30): + with timecop(946681223 + 30): self.assertFalse(steam.verify("4MK54")) @@ -498,29 +500,15 @@ def test_algorithms(self): pyotp.parse_uri("otpauth://totp?secret=abc&image=foobar") -class Timecop(object): - """ - Half-assed clone of timecop.rb, just enough to pass our tests. - """ +@contextlib.contextmanager +def timecop(freeze_timestamp: int) -> typing.Generator[None, None, None]: + class FrozenDateTime(datetime.datetime): + @classmethod + def now(cls, tz: datetime.tzinfo | None = None) -> "FrozenDateTime": + return cls.fromtimestamp(freeze_timestamp, tz=tz) - def __init__(self, freeze_timestamp): - self.freeze_timestamp = freeze_timestamp - - def __enter__(self): - self.real_datetime = datetime.datetime - datetime.datetime = self.frozen_datetime() - - def __exit__(self, type, value, traceback): - datetime.datetime = self.real_datetime - - def frozen_datetime(self): - class FrozenDateTime(datetime.datetime): - @classmethod - def now(cls, **kwargs): - return cls.fromtimestamp(timecop.freeze_timestamp) - - timecop = self - return FrozenDateTime + with unittest.mock.patch("datetime.datetime", FrozenDateTime): + yield if __name__ == "__main__": From 86a1bcad56bc8642dc19c82186eb187c21effad4 Mon Sep 17 00:00:00 2001 From: Jasper Spaans Date: Mon, 15 Sep 2025 13:51:10 +0200 Subject: [PATCH 6/7] refactor: remove testing of stdlib code This removes a bunch of test calls against compare_digest which is imported from the standard library, and not exposed to users of this library. Mypy also did not like this. --- test.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/test.py b/test.py index 42885ae..833ce35 100755 --- a/test.py +++ b/test.py @@ -331,8 +331,8 @@ def test_verify(self): self.assertFalse(steam.verify("4MK54")) -class CompareDigestTest(unittest.TestCase): - method = staticmethod(pyotp.utils.compare_digest) +class StringComparisonTest(unittest.TestCase): + method = staticmethod(pyotp.utils.strings_equal) def test_comparisons(self): self.assertTrue(self.method("", "")) @@ -343,10 +343,6 @@ def test_comparisons(self): self.assertFalse(self.method("a", "")) self.assertFalse(self.method("a" * 999 + "b", "a" * 1000)) - -class StringComparisonTest(CompareDigestTest): - method = staticmethod(pyotp.utils.strings_equal) - def test_fullwidth_input(self): self.assertTrue(self.method("xs12345", "xs12345")) From acd791791d8733a270bf0d6535484fccaf94c298 Mon Sep 17 00:00:00 2001 From: Jasper Spaans Date: Mon, 15 Sep 2025 13:57:11 +0200 Subject: [PATCH 7/7] lint: perform typing checks on test.py as well --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index d4ce561..fb5ea54 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ test_deps: lint: ruff check src - mypy --install-types --non-interactive --check-untyped-defs src + mypy --install-types --non-interactive --check-untyped-defs src test.py test: coverage run --branch --include 'src/*' -m unittest discover -s test -v