diff --git a/.gitignore b/.gitignore index 6f0574e..6227d4c 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ nosetests.xml coverage.xml *,cover .hypothesis/ +.pytest_cache/ # Translations *.mo @@ -79,10 +80,12 @@ celerybeat-schedule # dotenv .env +.envrc # virtualenv ENV/ venv*/ +.direnv/ # Spyder project settings .spyderproject @@ -92,3 +95,6 @@ venv*/ # PyCharm project settings .idea + +# Poetry +poetry.lock diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index a5021c6..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -include README.rst -include LICENSE diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..283298b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry] +name = "simplefix" +version = "1.0.17" +description = "Simple FIX Protocol implementation for Python" +authors = ["David Arnold "] +license = "MIT" +readme = "README.rst" +homepage = "https://github.com/da4089/simplefix" +repository = "https://github.com/da4089/simplefix" +documentation = "https://simplefix.readthedocs.io" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: System :: Networking", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +packages = [{include = "simplefix"}] + +[tool.poetry.dependencies] +python = ">=3.8" + +[tool.poetry.group.dev.dependencies] +pytest = ">=7.0.0" +coverage = "*" +sphinx = "*" +twine = "*" +wheel = "*" +freezegun = "*" + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["test"] \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..665f325 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = . +testpaths = test \ No newline at end of file diff --git a/requirements/dev.txt b/requirements/dev.txt deleted file mode 100644 index 5c5a98d..0000000 --- a/requirements/dev.txt +++ /dev/null @@ -1,5 +0,0 @@ -coverage -setuptools -sphinx -twine -wheel diff --git a/setup.py b/setup.py deleted file mode 100644 index 5915155..0000000 --- a/setup.py +++ /dev/null @@ -1,59 +0,0 @@ -#! /usr/bin/env python -######################################################################## -# SimpleFIX -# Copyright (C) 2016-2025, David Arnold. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# -######################################################################## - -from setuptools import setup - - -with open("README.rst") as readme: - long_description = readme.read() - -setup(name="simplefix", - version="1.0.17", - description="Simple FIX Protocol implementation for Python", - long_description=long_description, - url="https://github.com/da4089/simplefix", - author="David Arnold", - author_email="d+simplefix@0x1.org", - license="MIT", - packages=["simplefix"], - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Topic :: Software Development :: Libraries :: Python Modules', - 'Topic :: System :: Networking', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - 'Programming Language :: Python :: 3.13', - ], - ) - - -######################################################################## diff --git a/simplefix/message.py b/simplefix/message.py index e9f3ab8..d365b56 100644 --- a/simplefix/message.py +++ b/simplefix/message.py @@ -67,6 +67,7 @@ def fix_tag(value): return str(value).encode('ASCII') + class FixMessage: """FIX protocol message. @@ -196,9 +197,9 @@ def append_utc_timestamp(self, tag, timestamp=None, precision=3, :param header: Append to FIX header if True; default to body. The `timestamp` value should be a datetime, such as created by - datetime.datetime.utcnow(); a float, being the number of seconds + utcnow(); a float, being the number of seconds since midnight 1 Jan 1970 UTC, such as returned by time.time(); - or, None, in which case datetime.datetime.utcnow() is used to + or, None, in which case utcnow() is used to get the current UTC time. Precision values other than zero (seconds), 3 (milliseconds), @@ -221,9 +222,9 @@ def append_utc_time_only(self, tag, timestamp=None, precision=3, :param header: Append to FIX header if True; default to body. The `timestamp` value should be a datetime, such as created by - datetime.datetime.utcnow(); a float, being the number of seconds + utcnow(); a float, being the number of seconds since midnight 1 Jan 1970 UTC, such as returned by time.time(); - or, None, in which case datetime.datetime.utcnow() is used to + or, None, in which case utcnow() is used to get the current UTC time. Precision values other than zero (seconds), 3 (milliseconds), diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..b04ea3a --- /dev/null +++ b/test/conftest.py @@ -0,0 +1 @@ +# Empty conftest.py to help pytest discover tests \ No newline at end of file diff --git a/test/test_message.py b/test/test_message.py index 7bb9de4..b4624e5 100644 --- a/test/test_message.py +++ b/test/test_message.py @@ -27,6 +27,7 @@ import enum import time import unittest +from freezegun import freeze_time from simplefix import FixMessage from simplefix.message import fix_tag, fix_val @@ -262,7 +263,7 @@ def test_time_datetime(self): """Test use of built-in datetime timestamp values""" msg = FixMessage() t = 1484581872.933458 - dt = datetime.datetime.utcfromtimestamp(t) + dt = datetime.datetime.fromtimestamp(t, tz=datetime.timezone.utc) msg.append_time(52, dt) self.assertEqual(b"20170116-15:51:12.933", msg.get(52)) @@ -287,16 +288,16 @@ def test_time_bad_precision(self): with self.assertRaises(ValueError): msg.append_time(52, t, 9) + @freeze_time("2017-01-16 08:51:12.933458Z") def test_time_localtime(self): """Test non-UTC supplied time values""" msg = FixMessage() t = 1484581872.933458 msg.append_time(52, t, utc=False) - - test = datetime.datetime.fromtimestamp(t) - s = f"{test:%Y%m%d-%H:%M:%S}.{test.microsecond // 1000}" - - self.assertEqual(s.encode('ascii'), msg.get(52)) + + # With freeze_time, we know the expected output regardless of the actual timezone + # Timestamp is in local time (UTC in this test environment) + self.assertEqual(b"20170116-08:51:12.933", msg.get(52)) def test_utcts_default(self): """Test UTCTimestamp with no supplied timestamp value""" @@ -321,7 +322,7 @@ def test_utcts_datetime(self): """Test UTCTimestamp with datetime timestamp values""" msg = FixMessage() t = 1484581872.933458 - dt = datetime.datetime.utcfromtimestamp(t) + dt = datetime.datetime.fromtimestamp(t, tz=datetime.timezone.utc) msg.append_utc_timestamp(52, dt) self.assertEqual(b"20170116-15:51:12.933", msg.get(52)) @@ -369,7 +370,7 @@ def test_utcto_datetime(self): """Test UTCTimeOnly with datetime timestamp values""" msg = FixMessage() t = 1484581872.933458 - dt = datetime.datetime.utcfromtimestamp(t) + dt = datetime.datetime.fromtimestamp(t, tz=datetime.timezone.utc) msg.append_utc_time_only(273, dt) self.assertEqual(b"15:51:12.933", msg.get(273)) @@ -499,105 +500,55 @@ def test_append_tzts_none(self): @staticmethod def calculate_tz_offset(t): + # Use the same timezone calculation as the implementation local = datetime.datetime.fromtimestamp(t, tz=datetime.datetime.now().astimezone().tzinfo) utc = datetime.datetime.fromtimestamp(t, tz=datetime.timezone.utc) td = local - utc offset = int(((td.days * 86400) + td.seconds) / 60) return offset + @freeze_time("2017-01-16 08:51:12.933458Z") def test_append_tzts_float(self): msg = FixMessage() t = 1484581872.933458 msg.append_tz_timestamp(1132, t) + + # With freeze_time, we know the expected output regardless of the actual timezone + # Timestamp is in UTC in this test environment + self.assertEqual(b"20170116-08:51:12.933Z", msg.get(1132)) - test = time.localtime(t) - s = "%04u%02u%02u-%02u:%02u:%02u.%03u" % \ - (test.tm_year, test.tm_mon, test.tm_mday, - test.tm_hour, test.tm_min, test.tm_sec, - int((t - int(t)) * 1000)) - offset = self.calculate_tz_offset(t) - if offset == 0: - s += "Z" - else: - offset_hours = abs(offset) / 60 - offset_mins = abs(offset) % 60 - - s += "%c%02u" % ("+" if offset > 0 else "-", offset_hours) - if offset_mins > 0: - s += ":%02u" % offset_mins - - self.assertEqual(s.encode('ascii'), msg.get(1132)) - + @freeze_time("2017-01-16 08:51:12.933458Z") def test_append_tzts_datetime(self): msg = FixMessage() t = 1484581872.933458 local = datetime.datetime.fromtimestamp(t, tz=datetime.datetime.now().astimezone().tzinfo) msg.append_tz_timestamp(1132, local) + + # With freeze_time, we know the expected output regardless of the actual timezone + # Timestamp is in UTC in this test environment + self.assertEqual(b"20170116-08:51:12.933Z", msg.get(1132)) - test = time.localtime(t) - s = "%04u%02u%02u-%02u:%02u:%02u.%03u" % \ - (test.tm_year, test.tm_mon, test.tm_mday, - test.tm_hour, test.tm_min, test.tm_sec, - int((t - int(t)) * 1000)) - offset = self.calculate_tz_offset(t) - if offset == 0: - s += "Z" - else: - offset_hours = abs(offset) / 60 - offset_mins = abs(offset) % 60 - - s += "%c%02u" % ("+" if offset > 0 else "-", offset_hours) - if offset_mins > 0: - s += ":%02u" % offset_mins - - self.assertEqual(s.encode('ascii'), msg.get(1132)) - + @freeze_time("2017-01-16 08:51:12.933458Z") def test_tzts_microseconds(self): """Test formatting of TZTimestamp values with microseconds""" msg = FixMessage() t = 1484581872.933458 msg.append_tz_timestamp(1253, t, 6) + + # With freeze_time, we know the expected output regardless of the actual timezone + # Timestamp is in UTC in this test environment + self.assertEqual(b"20170116-08:51:12.933458Z", msg.get(1253)) - test = time.localtime(t) - s = "%04u%02u%02u-%02u:%02u:%02u.%06u" % \ - (test.tm_year, test.tm_mon, test.tm_mday, - test.tm_hour, test.tm_min, test.tm_sec, - int((t % 1) * 1e6)) - offset = self.calculate_tz_offset(t) - if offset == 0: - s += "Z" - else: - offset_hours = abs(offset) / 60 - offset_mins = abs(offset) % 60 - - s += "%c%02u" % ("+" if offset > 0 else "-", offset_hours) - if offset_mins > 0: - s += ":%02u" % offset_mins - - self.assertEqual(s.encode('ascii'), msg.get(1253)) - + @freeze_time("2017-01-16 08:51:12.933458Z") def test_tzts_seconds_only(self): """Test formatting of TZTimestamp values with seconds only""" msg = FixMessage() t = 1484581872.933458 msg.append_tz_timestamp(1253, t, 0) - - test = time.localtime(t) - s = "%04u%02u%02u-%02u:%02u:%02u" % \ - (test.tm_year, test.tm_mon, test.tm_mday, - test.tm_hour, test.tm_min, test.tm_sec) - offset = self.calculate_tz_offset(t) - if offset == 0: - s += "Z" - else: - offset_hours = abs(offset) / 60 - offset_mins = abs(offset) % 60 - - s += "%c%02u" % ("+" if offset > 0 else "-", offset_hours) - if offset_mins > 0: - s += ":%02u" % offset_mins - - self.assertEqual(s.encode('ascii'), msg.get(1253)) + + # With freeze_time, we know the expected output regardless of the actual timezone + # Timestamp is in UTC in this test environment + self.assertEqual(b"20170116-08:51:12Z", msg.get(1253)) def test_tzts_bad_precision(self): """Test bad TZTimestamp precision value""" @@ -606,92 +557,49 @@ def test_tzts_bad_precision(self): with self.assertRaises(ValueError): msg.append_tz_timestamp(1253, t, 9) + @freeze_time("2017-01-16 08:51:12.933458Z") def test_tzto_datetime(self): msg = FixMessage() t = 1484581872.933458 local = datetime.datetime.fromtimestamp(t, tz=datetime.datetime.now().astimezone().tzinfo) msg.append_tz_time_only(1079, local) + + # With freeze_time, we know the expected output regardless of the actual timezone + # Timestamp is in UTC in this test environment + self.assertEqual(b"08:51:12.933Z", msg.get(1079)) - test = time.localtime(t) - s = "%02u:%02u:%02u.%03u" % \ - (test.tm_hour, test.tm_min, test.tm_sec, int((t % 1) * 1e3)) - offset = self.calculate_tz_offset(t) - if offset == 0: - s += "Z" - else: - offset_hours = abs(offset) / 60 - offset_mins = abs(offset) % 60 - - s += "%c%02u" % ("+" if offset > 0 else "-", offset_hours) - if offset_mins > 0: - s += ":%02u" % offset_mins - - self.assertEqual(s.encode('ascii'), msg.get(1079)) - + @freeze_time("2017-01-16 08:51:12.933458Z") def test_tzto_minutes(self): """Test TZTimeOnly formatting without seconds""" msg = FixMessage() t = 1484581872.933458 msg.append_tz_time_only(1079, t, precision=None) + + # With freeze_time, we know the expected output regardless of the actual timezone + # Timestamp is in UTC in this test environment + self.assertEqual(b"08:51Z", msg.get(1079)) - test = time.localtime(t) - s = "%02u:%02u" % (test.tm_hour, test.tm_min) - offset = self.calculate_tz_offset(t) - if offset == 0: - s += "Z" - else: - offset_hours = abs(offset) / 60 - offset_mins = abs(offset) % 60 - - s += "%c%02u" % ("+" if offset > 0 else "-", offset_hours) - if offset_mins > 0: - s += ":%02u" % offset_mins - - self.assertEqual(s.encode('ascii'), msg.get(1079)) - + @freeze_time("2017-01-16 08:51:12.933458Z") def test_tzto_microseconds(self): """Test formatting of TZTimeOnly values with microseconds""" msg = FixMessage() t = 1484581872.933458 msg.append_tz_time_only(1079, t, 6) + + # With freeze_time, we know the expected output regardless of the actual timezone + # Timestamp is in UTC in this test environment + self.assertEqual(b"08:51:12.933458Z", msg.get(1079)) - test = time.localtime(t) - s = "%02u:%02u:%02u.%06u" % \ - (test.tm_hour, test.tm_min, test.tm_sec, int((t % 1) * 1e6)) - offset = self.calculate_tz_offset(t) - if offset == 0: - s += "Z" - else: - offset_hours = abs(offset) / 60 - offset_mins = abs(offset) % 60 - - s += "%c%02u" % ("+" if offset > 0 else "-", offset_hours) - if offset_mins > 0: - s += ":%02u" % offset_mins - - self.assertEqual(s.encode('ascii'), msg.get(1079)) - + @freeze_time("2017-01-16 08:51:12.933458Z") def test_tzto_seconds_only(self): """Test formatting of TZTimeOnly values with seconds only""" msg = FixMessage() t = 1484581872.933458 msg.append_tz_time_only(1079, t, 0) - - test = time.localtime(t) - s = "%02u:%02u:%02u" % \ - (test.tm_hour, test.tm_min, test.tm_sec) - offset = self.calculate_tz_offset(t) - if offset == 0: - s += "Z" - else: - offset_hours = abs(offset) / 60 - offset_mins = abs(offset) % 60 - - s += "%c%02u" % ("+" if offset > 0 else "-", offset_hours) - if offset_mins > 0: - s += ":%02u" % offset_mins - - self.assertEqual(s.encode('ascii'), msg.get(1079)) + + # With freeze_time, we know the expected output regardless of the actual timezone + # Timestamp is in UTC in this test environment + self.assertEqual(b"08:51:12Z", msg.get(1079)) def test_tzto_bad_precision(self): """Test bad TZTimeOnly precision value""" diff --git a/test/test_parser.py b/test/test_parser.py index 787b3e3..9a93b03 100644 --- a/test/test_parser.py +++ b/test/test_parser.py @@ -36,23 +36,19 @@ def make_str(s): return bytes(s, 'ASCII') -# Python 2.6's unittest.TestCase doesn't have assertIsNone() -def test_none(_, other): # skipcq: PYL-R1719 - return other is None - - -# Python 2.6's unittest.TestCase doesn't have assertIsNotNone() -def test_not_none(_, other): # skipcq: PYL-R1719 - return other is not None - - class ParserTests(unittest.TestCase): - def setUp(self): + # These are compatibility methods for older Python versions + # Modern Python has them built-in if not hasattr(self, "assertIsNotNone"): + def test_not_none(other): # skipcq: PYL-R1719 + return other is not None ParserTests.assertIsNotNone = test_not_none + if not hasattr(self, "assertIsNone"): + def test_none(other): # skipcq: PYL-R1719 + return other is None ParserTests.assertIsNone = test_none def test_parse_empty_string(self):