Python tomllib + Decimal: อ่านเลขทศนิยมจาก config โดยไม่เสียเศษ
การอ่าน TOML ด้วย tomllib ปกติแปลงเลขทศนิยมเป็น float ซึ่งเหมาะกับงานทั่วไป แต่ค่าที่ต้องรักษาฐานสิบ เช่นราคา อัตราภาษี หรือ quota อาจไม่ต้องการความคลาดจากเลขฐานสอง พารามิเตอร์ parse_float ให้เราเลือก constructor ตั้งแต่จังหวะ parser เห็นตัวเลขครับ
WHY: อย่ารอให้คลาดแล้วค่อยแปลงกลับ
การทำ Decimal จาก 0.1 ที่เป็น float แล้วเหมือนถ่ายเอกสารที่เบลอก่อนนำไปสแกนความละเอียดสูง รายละเอียดที่เสียไปไม่ได้กลับมา ทางที่ถูกคือให้ tomllib ส่งข้อความของ token ทศนิยมตรงเข้า Decimal ด้วย parse_float
from decimal import Decimal
import io
import tomllib
import unittest
CONFIG = b"""
[invoice]
subtotal = 0.10
tax_rate = 0.07
count = 3
"""
class TomlDecimalTests(unittest.TestCase):
def test_parse_float_uses_decimal(self):
data = tomllib.load(io.BytesIO(CONFIG), parse_float=Decimal)
invoice = data["invoice"]
self.assertIsInstance(invoice["subtotal"], Decimal)
self.assertEqual(invoice["subtotal"], Decimal("0.10"))
self.assertEqual(invoice["tax_rate"], Decimal("0.07"))
self.assertIsInstance(invoice["count"], int)
self.assertEqual(invoice["subtotal"] * invoice["count"], Decimal("0.30"))
if __name__ == "__main__":
unittest.main()
callback ใช้เฉพาะ TOML float; integer ยังเป็น int และ date/time ยังเป็นชนิดของ datetime ตามเดิม จุดนี้ดีเพราะเราเปลี่ยนเฉพาะสิ่งที่ตั้งใจ แต่ต้องแน่ใจว่า callback ไม่คืน dict หรือ list เพราะเอกสารห้าม เพื่อไม่ให้ชนกับโครงสร้างภายใน parser
Trade-off ที่ต้องตกลงกับทีม
Decimal แม่นฐานสิบและกำหนด rounding ได้ แต่ช้ากว่า float และ library บางตัว serialize ไม่ได้อัตโนมัติ ถ้า config เป็นพิกัดกราฟิกหรือค่าประมาณทางวิทยาศาสตร์ float อาจตรงโจทย์กว่า ถ้าเป็นเงิน ให้กำหนด currency, scale และ rounding policy เพิ่ม ไม่ใช่หวังว่า Decimal อย่างเดียวแก้ domain rule ทุกอย่าง
เมื่อเปิดไฟล์จริง ใช้โหมดไบนารีเพราะ tomllib.load คาด binary file object ส่วน tomllib.loads รับ string และใช้ parse_float แบบเดียวกัน อย่าผสม float กับ Decimal ในสูตรโดยไม่ตั้งใจ เพราะ Python จะปฏิเสธบาง operation เพื่อไม่ให้ความหมายพร่า
Recap
- ส่ง parse_float เป็น Decimal ตอน parse ไม่ใช่หลัง parse
- integer และวันเวลายังคงชนิดเดิม
- กำหนด rounding/currency policy แยกจากชนิดข้อมูล
- ทดสอบ serialization boundary ที่ต้องส่ง Decimal ต่อ