Skip to content

Commit d7ba027

Browse files
release: 0.6.1
1 parent 9f3d701 commit d7ba027

6 files changed

Lines changed: 1990 additions & 10 deletions

File tree

docs/docs/content/CHANGELOG.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,23 @@ All notable changes to Rayforce-Py will be documented in this file.
1616
- **Slicing**: `table[1:3]`, `table[:5]`, `table[-2:]` — row slicing backed by the C-level `TAKE` operation.
1717
- **Index list**: `table[[0, 2, 5]]` — select specific rows by position.
1818

19+
- **`Vector.from_numpy()` auto-widening**: Unsupported numpy dtypes are now automatically widened to the nearest supported type: `float32`/`float16``F64`, `int8``I16`, `uint16``I32`, `uint32``I64`.
20+
21+
- **`Vector.from_numpy()` bytes and UUID support**: Byte string arrays (`dtype='S'`) are automatically decoded to Symbol vectors. Object arrays of `uuid.UUID` values are detected and converted to GUID vectors.
22+
23+
- **NaT preservation**: `NaT` (Not-a-Time) values in numpy `datetime64` and `timedelta64` arrays now survive round-trips through `Vector.from_numpy()` and `Vector.to_numpy()`.
24+
1925
### Bug Fixes
2026

2127
- **`Table.to_numpy()` with Timestamp columns**: Fixed `DTypePromotionError` when calling `to_numpy()` on tables containing a mix of incompatible column types (e.g., integers, strings, and timestamps). Mixed-type tables now gracefully fall back to `object` dtype.
2228

2329
- **Filtering F64 by distinct** - fixed
2430

25-
2026-02-23 | **[🔗 PyPI](https://pypi.org/project/rayforce-py/0.6.1/)** | **[🔗 GitHub](https://github.com/RayforceDB/rayforce-py/releases/tag/0.6.1)**
31+
- **`Vector.__getitem__` for U8 vectors**: Fixed U8 vector elements being returned as `B8(True/False)` instead of `U8(value)`. Both types are 1-byte, causing the C-level `at_idx` to misinterpret the type.
32+
33+
- **`Vector.from_numpy()` with explicit `ray_type` for temporal arrays**: Fixed `ValueError: cannot include dtype 'M' in a buffer` when passing `ray_type=Timestamp`, `ray_type=Date`, or `ray_type=Time` with datetime64/timedelta64 arrays.
34+
35+
2026-03-03 | **[🔗 PyPI](https://pypi.org/project/rayforce-py/0.6.1/)** | **[🔗 GitHub](https://github.com/RayforceDB/rayforce-py/releases/tag/0.6.1)**
2636

2737

2838
## **`0.6.0`**

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "rayforce_py"
7-
version = "0.6.0"
7+
version = "0.6.1"
88
description = "Python bindings for RayforceDB"
99
readme = "README.md"
1010
authors = [{name = "Karim"}]

rayforce/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
FFI.init_runtime()
1212

13-
version = "0.6.0"
13+
version = "0.6.1"
1414

1515
if sys.platform == "linux":
1616
lib_name = "_rayforce_c.so"

rayforce/types/containers/vector.py

Lines changed: 93 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import struct
44
import typing as t
5+
import uuid
56

67
import numpy as np
78

@@ -22,6 +23,7 @@
2223
)
2324
from rayforce.types.containers.list import List
2425
from rayforce.types.operators import Operation
26+
from rayforce.types.scalars.numeric.unsigned import U8
2527
from rayforce.types.scalars.other.symbol import Symbol
2628

2729
_RAW_FORMATS: dict[int, tuple[str, int]] = {
@@ -43,9 +45,19 @@
4345
"int64": r.TYPE_I64,
4446
"float64": r.TYPE_F64,
4547
}
48+
# Auto-widen numpy dtypes that have no direct rayforce equivalent
49+
_NUMPY_WIDEN: dict[str, str] = {
50+
"float16": "float64",
51+
"float32": "float64",
52+
"int8": "int16",
53+
"uint16": "int32",
54+
"uint32": "int64",
55+
}
4656
# Epoch offset: rayforce uses 2000-01-01, numpy uses 1970-01-01
4757
_EPOCH_OFFSET_DAYS = 10_957 # (2000-01-01 - 1970-01-01).days
4858
_EPOCH_OFFSET_NS = _EPOCH_OFFSET_DAYS * 86_400 * 1_000_000_000
59+
_I32_NULL = np.int32(np.iinfo(np.int32).min) # -2147483648
60+
_I64_NULL = np.int64(np.iinfo(np.int64).min) # iNaT
4961

5062
_NUMPY_DTYPES: dict[int, t.Any] = {
5163
r.TYPE_U8: np.uint8,
@@ -117,6 +129,13 @@ def __getitem__(self, idx: int) -> t.Any:
117129
if idx < 0 or idx >= len(self):
118130
raise errors.RayforceIndexError(f"Vector index out of range: {idx}")
119131

132+
# The C-level at_idx returns B8 scalars for U8 vector elements
133+
# because both are 1-byte types and the value loses precision (bool).
134+
# Read the correct byte value directly from the raw buffer instead.
135+
if FFI.get_obj_type(self.ptr) == r.TYPE_U8:
136+
raw = FFI.read_vector_raw(self.ptr)
137+
return U8(raw[idx])
138+
120139
return utils.ray_to_python(FFI.at_idx(self.ptr, idx))
121140

122141
def __setitem__(self, idx: int, value: t.Any) -> None:
@@ -125,7 +144,25 @@ def __setitem__(self, idx: int, value: t.Any) -> None:
125144
if not 0 <= idx < len(self):
126145
raise errors.RayforceIndexError(f"Vector index out of range: {idx}")
127146

128-
FFI.insert_obj(iterable=self.ptr, idx=idx, ptr=utils.python_to_ray(value))
147+
# Coerce plain Python values to the vector's element type so that
148+
# insert_obj receives a scalar whose type matches the vector.
149+
# Without this, e.g. inserting an int (I64) into an I16 vector
150+
# corrupts the vector type at the C level.
151+
from rayforce.types.null import Null
152+
from rayforce.types.registry import TypeRegistry
153+
154+
vec_type = FFI.get_obj_type(self.ptr)
155+
scalar_class = TypeRegistry.get(-vec_type)
156+
if (
157+
scalar_class is not None
158+
and scalar_class is not Null
159+
and not isinstance(value, RayObject)
160+
):
161+
ptr = scalar_class(value).ptr # type: ignore[call-arg]
162+
else:
163+
ptr = utils.python_to_ray(value)
164+
165+
FFI.insert_obj(iterable=self.ptr, idx=idx, ptr=ptr)
129166

130167
def __iter__(self) -> t.Iterator[t.Any]:
131168
for i in range(len(self)):
@@ -145,10 +182,29 @@ def to_numpy(self) -> t.Any:
145182
return np.array(self.to_list())
146183
raw = np.frombuffer(FFI.read_vector_raw(self.ptr), dtype=dtype)
147184
if type_code == r.TYPE_TIMESTAMP:
185+
null_mask = raw == _I64_NULL
186+
if null_mask.any():
187+
adjusted = raw.copy()
188+
adjusted[null_mask] = 0
189+
result = (adjusted + _EPOCH_OFFSET_NS).view("datetime64[ns]").copy()
190+
result[null_mask] = np.datetime64("NaT")
191+
return result
148192
return (raw + _EPOCH_OFFSET_NS).view("datetime64[ns]")
149193
if type_code == r.TYPE_DATE:
194+
null_mask = raw == _I32_NULL
195+
if null_mask.any():
196+
safe = raw.astype(np.int64)
197+
safe[null_mask] = 0
198+
result = (safe + _EPOCH_OFFSET_DAYS).astype("datetime64[D]")
199+
result[null_mask] = np.datetime64("NaT")
200+
return result
150201
return (raw.astype(np.int64) + _EPOCH_OFFSET_DAYS).astype("datetime64[D]")
151202
if type_code == r.TYPE_TIME:
203+
null_mask = raw == _I32_NULL
204+
if null_mask.any():
205+
result = raw.astype("timedelta64[ms]").copy()
206+
result[null_mask] = np.timedelta64("NaT")
207+
return result
152208
return raw.astype("timedelta64[ms]")
153209
return raw
154210

@@ -157,38 +213,69 @@ def from_numpy(cls, arr: t.Any, *, ray_type: type[RayObject] | int | None = None
157213
if not isinstance(arr, np.ndarray):
158214
raise errors.RayforceInitError("from_numpy requires a numpy ndarray")
159215

216+
if arr.ndim != 1:
217+
raise errors.RayforceInitError(
218+
f"from_numpy requires a 1-D array, got {arr.ndim}-D array with shape {arr.shape}"
219+
)
220+
160221
arr = np.ascontiguousarray(arr)
161222

162-
if ray_type is not None:
223+
if ray_type is not None and arr.dtype.kind not in ("M", "m"):
163224
type_code = abs(ray_type if isinstance(ray_type, int) else ray_type.type_code)
164225
return cls(ptr=FFI.init_vector_from_raw_buffer(type_code, len(arr), arr.data))
165226

166227
if (maybe_code := _NUMPY_TO_RAY.get(arr.dtype.name)) is not None:
167228
return cls(ptr=FFI.init_vector_from_raw_buffer(maybe_code, len(arr), arr.data))
168229

230+
# Auto-widen common dtypes (float32 → float64, int8 → int16)
231+
if (widen_to := _NUMPY_WIDEN.get(arr.dtype.name)) is not None:
232+
arr = arr.astype(widen_to)
233+
code = _NUMPY_TO_RAY[widen_to]
234+
return cls(ptr=FFI.init_vector_from_raw_buffer(code, len(arr), arr.data))
235+
169236
# datetime64 -> Timestamp or Date
170237
if arr.dtype.kind == "M":
171238
resolution = np.datetime_data(arr.dtype)[0]
172239
if resolution == "D":
173-
int_arr = (arr.view(np.int64) - _EPOCH_OFFSET_DAYS).astype(np.int32)
240+
raw_i64 = arr.view(np.int64)
241+
nat_mask = raw_i64 == _I64_NULL
242+
int_arr = (raw_i64 - _EPOCH_OFFSET_DAYS).astype(np.int32)
243+
if nat_mask.any():
244+
int_arr[nat_mask] = _I32_NULL
174245
return cls(
175246
ptr=FFI.init_vector_from_raw_buffer(r.TYPE_DATE, len(int_arr), int_arr.data)
176247
)
177248
# Any other resolution -> convert to ns -> Timestamp
178-
ns_arr = arr.astype("datetime64[ns]").view(np.int64) - _EPOCH_OFFSET_NS
249+
ns_view = arr.astype("datetime64[ns]").view(np.int64)
250+
nat_mask = ns_view == _I64_NULL
251+
ns_arr = ns_view.copy()
252+
ns_arr[~nat_mask] -= _EPOCH_OFFSET_NS
253+
# NaT positions keep int64 min (= rayforce null sentinel)
179254
return cls(
180255
ptr=FFI.init_vector_from_raw_buffer(r.TYPE_TIMESTAMP, len(ns_arr), ns_arr.data)
181256
)
182257

183258
# timedelta64 -> Time (milliseconds since midnight)
184259
if arr.dtype.kind == "m":
185260
arr_ms = arr.astype("timedelta64[ms]")
186-
int_arr = arr_ms.view(np.int64).astype(np.int32)
261+
raw_i64 = arr_ms.view(np.int64)
262+
nat_mask = raw_i64 == _I64_NULL
263+
int_arr = raw_i64.astype(np.int32)
264+
if nat_mask.any():
265+
int_arr[nat_mask] = _I32_NULL
187266
return cls(ptr=FFI.init_vector_from_raw_buffer(r.TYPE_TIME, len(int_arr), int_arr.data))
188267

189268
# String/object arrays
190269
if arr.dtype.kind in ("U", "S", "O"):
191-
return cls(items=arr.tolist(), ray_type=Symbol)
270+
items = arr.tolist()
271+
if arr.dtype.kind == "S":
272+
items = [x.decode() if isinstance(x, bytes) else x for x in items]
273+
# Detect UUID objects in object arrays → GUID vector
274+
if arr.dtype.kind == "O" and items and isinstance(items[0], uuid.UUID):
275+
from rayforce.types.scalars.other.guid import GUID
276+
277+
return cls(items=items, ray_type=GUID)
278+
return cls(items=items, ray_type=Symbol)
192279

193280
raise errors.RayforceInitError(
194281
f"Cannot infer ray_type from numpy dtype '{arr.dtype.name}'. "

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ def has_ext_modules(self):
99

1010
setup(
1111
name="rayforce_py",
12-
version="0.6.0",
12+
version="0.6.1",
1313
packages=find_packages(),
1414
package_data={
1515
"rayforce": ["*.so", "*.dylib", "*.pyi", "bin/rayforce"],

0 commit comments

Comments
 (0)