diff --git a/CHANGELOG.md b/CHANGELOG.md index 58260f6..98d9f30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,12 @@ # Changelog +## 3.0.2 (2026-04-22) + * Handle Decimal objects in messages + +## 3.0.1 (2026-04-21) + * Fixed PyPI publisher + +## 3.0.0 (2026-04-20) + * Support for Python 3.12 ## 2.0.1 (2021-11-23) * Fixed an issue when `format_message` returned newline character diff --git a/setup.py b/setup.py index 479ee50..4019fed 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ long_description = fh.read() setup(name="pipelinewise-singer-python", - version='3.0.1', + version='3.0.2', description="Singer.io utility library - PipelineWise compatible", python_requires=">=3.12, <3.13", long_description=long_description, diff --git a/singer/__init__.py b/singer/__init__.py index 5b72276..4f4e8e1 100644 --- a/singer/__init__.py +++ b/singer/__init__.py @@ -35,7 +35,8 @@ write_schema, write_state, write_version, - write_batch + write_batch, + handler_for_decimal_object ) from singer.transform import ( diff --git a/singer/messages.py b/singer/messages.py index e6c8247..80ac204 100644 --- a/singer/messages.py +++ b/singer/messages.py @@ -3,6 +3,7 @@ import pytz import orjson import ciso8601 +import decimal import singer.utils as u from .logger import get_logger @@ -291,9 +292,13 @@ def parse_message(msg): return None +def handler_for_decimal_object(obj): + if isinstance(obj, decimal.Decimal): + return float(obj) + raise TypeError def format_message(message, option=0): - return orjson.dumps(message.asdict(), option=option) + return orjson.dumps(message.asdict(), option=option, default=handler_for_decimal_object) def write_message(message): diff --git a/tests/test_singer.py b/tests/test_singer.py index dd72d4a..093f475 100644 --- a/tests/test_singer.py +++ b/tests/test_singer.py @@ -2,6 +2,9 @@ import orjson import unittest import dateutil +import decimal + +from unittest.mock import MagicMock class TestSinger(unittest.TestCase): @@ -206,6 +209,45 @@ def test_format_message(self): self.assertEqual(b'{"type":"RECORD","stream":"users","record":{"name":"foo"}}\n', singer.format_message(record_message, option=orjson.OPT_APPEND_NEWLINE)) + def test_default_handler_decimal(self): + """Test that decimal.Decimal is converted to a string.""" + d = decimal.Decimal("10.50") + result = singer.handler_for_decimal_object(d) + self.assertEqual(result, 10.50) + self.assertIsInstance(result, float) + + def test_default_handler_error(self): + """Test that unsupported types still raise a TypeError.""" + with self.assertRaises(TypeError): + singer.handler_for_decimal_object(range(5)) + + def test_format_message_with_decimals(self): + """Test that format_message correctly serializes a message containing decimals.""" + # Mocking the message object + mock_message = MagicMock() + mock_message.asdict.return_value = { + "id": 1, + "price": decimal.Decimal("99.99"), + "status": "active" + } + + # We expect the Decimal to become a string in the resulting bytes + expected_output = b'{"id":1,"price":99.99,"status":"active"}' + + result = singer.format_message(mock_message) + self.assertEqual(result, expected_output) + + def test_format_message_options(self): + """Test that orjson options (like appending a newline) work.""" + mock_message = MagicMock() + mock_message.asdict.return_value = {"key": "val"} + + # orjson.OPT_APPEND_NEWLINE is an integer bitmask + result = singer.format_message(mock_message, option=orjson.OPT_APPEND_NEWLINE) + + # Should end with a newline byte (10) + self.assertTrue(result.endswith(b'\n')) + if __name__ == '__main__': unittest.main()