Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 165 additions & 4 deletions comtypes/automation.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,38 @@
_CArgObject = type(byref(c_int()))


# ============================================================
# Fixed-point number (VT_CY / VT_DECIMAL) marshaling helpers
# ============================================================

# CY range constants
_CY_BITS = 64
_LLONG_MIN = -(1 << (_CY_BITS - 1))
_LLONG_MAX = (1 << (_CY_BITS - 1)) - 1
_CY_SCALE = decimal.Decimal("10000")
_CY_MIN = decimal.Decimal(_LLONG_MIN) / _CY_SCALE
_CY_MAX = decimal.Decimal(_LLONG_MAX) / _CY_SCALE

# DECIMAL limits
_DECIMAL_MAX_SCALE = 28
_DECIMAL_MAX_DIGITS = 96 # 96 bits = max 28 decimal digits (2**96-1 has 29 digits, but we use 96-bit mantissa)
_DECIMAL_MAX_MANTISSA = (1 << 96) - 1


def _coerce_decimal(value: Any, context: str) -> decimal.Decimal:
"""Coerce a value to Decimal for VARIANT marshaling.

Accepts only Decimal. Rejects int, float, bool, NaN, and Infinity.
"""
if not isinstance(value, decimal.Decimal):
raise TypeError(f"{context}: only Decimal is accepted")
if value.is_nan():
raise ValueError(f"{context}: NaN cannot be converted to VARIANT")
if value.is_infinite():
raise OverflowError(f"{context}: Infinity cannot be converted to VARIANT")
return value


LCID = DWORD
DISPID = LONG
SCODE = LONG
Expand Down Expand Up @@ -111,6 +143,37 @@
class tagCY(Structure):
_fields_ = [("int64", c_longlong)]

def as_decimal(self) -> decimal.Decimal:
"""Convert a tagCY struct to Decimal with scale 4 (10000)."""
return decimal.Decimal(self.int64) / _CY_SCALE

def __repr__(self) -> str:
return f"tagCY(int64={self.int64})"

@classmethod
def from_decimal(cls, d: decimal.Decimal) -> "hints.Self":
"""Create a tagCY from a Decimal.

The value is multiplied by 10000 and must be exactly representable
as an integer (no fractional part after scaling). Raises OverflowError
if the value has more than 4 decimal places, or if the value is
outside the int64 range.
"""
d = _coerce_decimal(d, "tagCY.from_decimal")
# Use localcontext with high precision to avoid context-dependent rounding
with decimal.localcontext(decimal.Context(prec=decimal.MAX_PREC)):
scaled = d * _CY_SCALE
# Check if the scaled value has a fractional part
if scaled != scaled.to_integral_value():
raise OverflowError(
f"Value {d} has more than 4 decimal places, cannot convert to VT_CY exactly. "
f"Round explicitly before calling."
)
n = int(scaled)
if not _LLONG_MIN <= n <= _LLONG_MAX:
raise OverflowError(f"Value {d} out of range for VT_CY")
return cls(n)


CY = tagCY
CURRENCY = CY
Expand All @@ -136,6 +199,92 @@ def as_decimal(self) -> decimal.Decimal:
sign = "-" if self.sign else ""
return decimal.Decimal(f"{sign}{digits}e-{self.scale}")

def __repr__(self) -> str:
return f"tagDEC(scale={self.scale}, sign={self.sign}, Hi32={self.Hi32}, Lo64={self.Lo64})"

@classmethod
def from_decimal(cls, d: decimal.Decimal) -> "hints.Self":
"""Create a tagDEC from a Decimal.

The scale is chosen as min(max(-exponent, 0), 28) for zero values,
and min(-exponent, 28) for non-zero values. Trailing zeros in the
fractional part are stripped only when they would cause the scale
to exceed 28 (i.e., when exponent < -28). This allows values like
1.230000... (many trailing zeros) to be accepted by reducing scale.

Returns a tagDEC instance.

Raises:
TypeError: If d is not a Decimal.
ValueError: If d is NaN.
OverflowError: If:
- d is Infinity.
- The value has more than 28 non-zero decimal places (exceeds
VT_DECIMAL maximum scale).
- The value exceeds the 96-bit mantissa (≈7.9e28) at the
required scale.
"""
d = _coerce_decimal(d, "tagDEC.from_decimal")
sign, digits, exp = d.as_tuple()
coef = int("".join(map(str, digits))) if digits else 0

# Handle zero specially - preserve scale and sign for round-trip fidelity
if coef == 0:
# Zero: scale = min(max(-exp, 0), 28) to preserve trailing zeros
scale = min(max(-exp, 0), _DECIMAL_MAX_SCALE)
scaled_coef = 0
else:
# Strip trailing zeros from coefficient ONLY when they would cause
# scale to exceed 28 (i.e., when exp < -28). This allows values like
# 1.230000... (many trailing zeros) to be accepted by reducing scale.
while exp < -_DECIMAL_MAX_SCALE and coef % 10 == 0:
coef //= 10
exp += 1

# Check for huge positive exponent that would cause huge integer allocation
# 96-bit mantissa max is 2**96-1 ≈ 7.9e28, so 29+ digits exceeds it.
# Use 29 explicitly (not _DECIMAL_MAX_SCALE + 1) to avoid confusion.
if d.adjusted() >= 29:
raise OverflowError(
f"Value {d} exceeds 96-bit mantissa. "
f"Reduce precision explicitly before calling."
)

# Start with scale = min(-exp, 28)
scale = min(-exp, _DECIMAL_MAX_SCALE) if exp < 0 else 0

# If the value has more decimal places than max scale, it cannot be
# represented exactly in VT_DECIMAL. Require explicit rounding by caller.
if exp < -_DECIMAL_MAX_SCALE:
raise OverflowError(
f"Value {d} has more than {_DECIMAL_MAX_SCALE} decimal places, "
f"cannot convert to VT_DECIMAL exactly. Round explicitly before calling."
)

# After the check above, we know exp >= -28 (or exp >= 0).
# For exp >= 0: scale=0, multiply by 10**exp.
# For exp < 0: scale=-exp, use coef directly (no scaling needed).
if exp >= 0:
scaled_coef = coef * 10**exp
else:
scaled_coef = coef

# Check if it fits in 96 bits at the required scale
if scaled_coef > _DECIMAL_MAX_MANTISSA:
raise OverflowError(
f"Value {d} exceeds 96-bit mantissa at scale {scale}. "
f"Reduce precision explicitly before calling."
)

# Build the tagDEC
dec = cls()
dec.wReserved = 0
dec.scale = scale
dec.sign = 0x80 if sign else 0x00
dec.Hi32 = (scaled_coef >> 64) & 0xFFFFFFFF
dec.Lo64 = scaled_coef & 0xFFFFFFFFFFFFFFFF
return dec


DECIMAL = tagDEC

Expand Down Expand Up @@ -173,7 +322,7 @@ class _tagBRECORD(Structure):
("scode", SCODE),
("VT_R4", c_float),
("VT_R8", c_double),
("VT_CY", c_longlong),
("VT_CY", tagCY),
("c_wchar_p", c_wchar_p),
("c_void_p", c_void_p),
("pparray", POINTER(POINTER(_safearray.tagSAFEARRAY))),
Expand Down Expand Up @@ -304,8 +453,18 @@ def _set_value(self, value):
com_days /= comtypes.npsupport.numpy.timedelta64(1, "D")
self.vt = VT_DATE
self._.VT_R8 = com_days
elif decimal is not None and isinstance(value, decimal.Decimal):
self._.VT_CY = int(round(value * 10000))
elif isinstance(value, decimal.Decimal):
# TODO: Use `self._.VT_CY = tagCY.from_decimal(value)` directly.
# Currently writing via `VT_I8` because `VT_CY` is now a `tagCY`
# struct in the union. This is correct since `VT_I8` and `VT_CY`
# share the same memory in the union.
#
# Direct assignment to `VT_CY` would require constructing a
# `tagCY` instance. This is avoided because `tagCY.from_decimal()`
# raises `OverflowError` for values outside the CY range or with
# more than 4 decimal places, which would break user code that
# implicitly relied on rounding behavior.
self._.VT_I8 = int(round(value * _CY_SCALE))
self.vt = VT_CY
elif isinstance(value, POINTER(IDispatch)):
CopyComPointer(value, byref(self._))
Expand Down Expand Up @@ -471,7 +630,7 @@ def _get_value(self, dynamic=False):
days = self._.VT_R8
return datetime.timedelta(days=days) + _com_null_date
elif vt == VT_CY:
return self._.VT_CY / decimal.Decimal("10000")
return self._.VT_CY.as_decimal()
elif vt == VT_UNKNOWN:
val = self._.c_void_p
if not val:
Expand Down Expand Up @@ -1029,6 +1188,8 @@ def Invoke(self, dispid: int, *args: Any, **kw: Any) -> Any:
BSTR: VT_BSTR,
HRESULT: VT_HRESULT,
VARIANT: VT_VARIANT,
tagCY: VT_CY,
tagDEC: VT_DECIMAL,
# SAFEARRAY(VARIANT *)
#
# It is unlear to me if this is allowed or not. Apparently there
Expand Down
8 changes: 7 additions & 1 deletion comtypes/safearray.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ def unpack(self):
def _get_elements_raw(self, num_elements):
"""Returns a flat list or ndarray containing ALL elements in
the safearray."""
from comtypes.automation import VARIANT
from comtypes.automation import VARIANT, tagCY, tagDEC

# XXX Not sure this is true:
# For VT_UNKNOWN and VT_DISPATCH, we should retrieve the
Expand Down Expand Up @@ -327,6 +327,12 @@ def _get_elements_raw(self, num_elements):
# return a NULL-interface pointer.
result.append(POINTER(itf)())
return result
elif self._itemtype_ == tagCY:
# Convert tagCY to Decimal for easier use
return [x.as_decimal() for x in ptr[:num_elements]]
elif self._itemtype_ == tagDEC:
# Convert tagDEC to Decimal for easier use
return [x.as_decimal() for x in ptr[:num_elements]]
else:
# If the safearray element are NOT native python
# objects, the containing safearray must be kept
Expand Down
Loading
Loading