Skip to content

Commit 20da893

Browse files
committed
first rendering
1 parent 641598d commit 20da893

7 files changed

Lines changed: 297 additions & 0 deletions

File tree

README.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ ArrayKit requires the following:
3535
What is New in ArrayKit
3636
-------------------------
3737

38+
1.12.0
39+
............
40+
41+
Added ``map_object()``.
42+
43+
3844
1.11.0
3945
............
4046

src/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from ._arraykit import factorize as factorize
2929
from ._arraykit import group_ordering as group_ordering
3030
from ._arraykit import group_reduce as group_reduce
31+
from ._arraykit import map_object as map_object
3132
from ._arraykit import fill_directional as fill_directional
3233
from ._arraykit import count_iteration as count_iteration
3334
from ._arraykit import first_true_1d as first_true_1d

src/__init__.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ def group_ordering(
236236
def group_reduce(
237237
codes: np.ndarray, size: int, values: np.ndarray, op: str
238238
) -> np.ndarray: ...
239+
def map_object(array: np.ndarray, func: tp.Callable[[tp.Any], tp.Any]) -> np.ndarray: ...
239240
def fill_directional(
240241
array: np.ndarray,
241242
target: np.ndarray,

src/_arraykit.c

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ static PyMethodDef arraykit_methods[] = {
8282
(PyCFunction)group_reduce,
8383
METH_VARARGS | METH_KEYWORDS,
8484
NULL},
85+
{"map_object",
86+
(PyCFunction)map_object,
87+
METH_VARARGS | METH_KEYWORDS,
88+
NULL},
8589
{"fill_directional",
8690
(PyCFunction)fill_directional,
8791
METH_VARARGS | METH_KEYWORDS,

src/methods.c

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1403,6 +1403,149 @@ group_reduce(PyObject *Py_UNUSED(m), PyObject *args, PyObject *kwargs)
14031403
return out_arr;
14041404
}
14051405

1406+
// int magnitude beyond which a Python int is no longer losslessly coercible to float;
1407+
// mirrors static_frame.core.util.INT_MAX_COERCIBLE_TO_FLOAT
1408+
#define AK_INT_MAX_COERCIBLE_TO_FLOAT 1000000000000000LL
1409+
1410+
static char *map_object_kwarg_names[] = {
1411+
"array",
1412+
"func",
1413+
NULL
1414+
};
1415+
1416+
// Apply a Python callable to each element of a 1D array (elements boxed as numpy scalars,
1417+
// matching NumPy/Series iteration) and return a new 1D array, inferring the result dtype
1418+
// with the same rules as static_frame.core.util.prepare_iter_for_array: the result is an
1419+
// object array when the applied values mix strings and non-strings, include a sized object
1420+
// (tuple/list/array), an Enum, or mix a large Python int with a Python float/complex;
1421+
// otherwise NumPy auto-detects the dtype (e.g. str -> '<U', float -> float64). This fuses
1422+
// the per-element apply, the type inspection, and the array build into one C pass.
1423+
PyObject *
1424+
map_object(PyObject *Py_UNUSED(m), PyObject *args, PyObject *kwargs)
1425+
{
1426+
PyArrayObject *array = NULL;
1427+
PyObject *func = NULL;
1428+
if (!PyArg_ParseTupleAndKeywords(args, kwargs,
1429+
"O!O:map_object",
1430+
map_object_kwarg_names,
1431+
&PyArray_Type, &array,
1432+
&func
1433+
)) {
1434+
return NULL;
1435+
}
1436+
if (PyArray_NDIM(array) != 1) {
1437+
PyErr_SetString(PyExc_ValueError, "array must be 1-dimensional");
1438+
return NULL;
1439+
}
1440+
if (!PyCallable_Check(func)) {
1441+
PyErr_SetString(PyExc_TypeError, "func must be callable");
1442+
return NULL;
1443+
}
1444+
npy_intp n = PyArray_SIZE(array);
1445+
int is_object = PyArray_TYPE(array) == NPY_OBJECT;
1446+
1447+
PyObject *values = PyList_New(n); // collected results; owns references
1448+
if (values == NULL) {
1449+
return NULL;
1450+
}
1451+
// enum.Enum for the rare Enum-result case; on failure proceed without the check
1452+
PyObject *enum_type = NULL;
1453+
PyObject *enum_mod = PyImport_ImportModule("enum");
1454+
if (enum_mod != NULL) {
1455+
enum_type = PyObject_GetAttrString(enum_mod, "Enum");
1456+
Py_DECREF(enum_mod);
1457+
}
1458+
if (enum_type == NULL) {
1459+
PyErr_Clear();
1460+
}
1461+
1462+
// prepare_iter_for_array inference state
1463+
int has_str = 0;
1464+
int has_non_str = 0;
1465+
int has_inexact = 0;
1466+
int has_big_int = 0;
1467+
int needs_object = 0;
1468+
1469+
for (npy_intp i = 0; i < n; i++) {
1470+
PyObject *elem;
1471+
if (is_object) {
1472+
elem = *(PyObject**)PyArray_GETPTR1(array, i);
1473+
Py_INCREF(elem);
1474+
}
1475+
else {
1476+
elem = PyArray_ToScalar(PyArray_GETPTR1(array, i), array);
1477+
if (elem == NULL) {
1478+
goto fail;
1479+
}
1480+
}
1481+
PyObject *r = PyObject_CallOneArg(func, elem);
1482+
Py_DECREF(elem);
1483+
if (r == NULL) {
1484+
goto fail;
1485+
}
1486+
PyList_SET_ITEM(values, i, r); // steals reference to r
1487+
1488+
if (needs_object) {
1489+
continue; // dtype already resolved; keep collecting only
1490+
}
1491+
PyTypeObject *rt = Py_TYPE(r);
1492+
// exact str/bytes (Python or numpy scalar) -> string; subclasses fall through
1493+
if (PyUnicode_CheckExact(r) || PyBytes_CheckExact(r)
1494+
|| PyArray_IsScalar(r, Unicode) || PyArray_IsScalar(r, String)) {
1495+
has_str = 1;
1496+
}
1497+
// a sized object (tuple, list, array, SF container, str subclass) -> object
1498+
else if ((rt->tp_as_sequence && rt->tp_as_sequence->sq_length)
1499+
|| (rt->tp_as_mapping && rt->tp_as_mapping->mp_length)) {
1500+
needs_object = 1;
1501+
}
1502+
else {
1503+
has_non_str = 1;
1504+
if (rt == &PyFloat_Type || rt == &PyComplex_Type) {
1505+
has_inexact = 1;
1506+
}
1507+
else if (rt == &PyLong_Type) {
1508+
int overflow = 0;
1509+
long long lv = PyLong_AsLongLongAndOverflow(r, &overflow);
1510+
if (overflow || llabs(lv) > AK_INT_MAX_COERCIBLE_TO_FLOAT) {
1511+
has_big_int = 1;
1512+
}
1513+
}
1514+
else if (PyArray_IsScalar(r, Generic)) {
1515+
; // any other numpy scalar: non-str, no inexact/big-int, not an Enum
1516+
}
1517+
else if (enum_type != NULL && PyObject_IsInstance(r, enum_type) == 1) {
1518+
needs_object = 1;
1519+
}
1520+
}
1521+
if ((has_str && has_non_str) || (has_big_int && has_inexact)) {
1522+
needs_object = 1;
1523+
}
1524+
}
1525+
Py_XDECREF(enum_type);
1526+
1527+
PyObject *result;
1528+
if (needs_object) {
1529+
// build an object array of the collected values
1530+
result = PyArray_FROM_OTF(values, NPY_OBJECT, NPY_ARRAY_C_CONTIGUOUS);
1531+
}
1532+
else {
1533+
// let NumPy auto-detect the dtype from the values (str -> '<U', float -> f8, ...)
1534+
result = PyArray_FromAny(values, NULL, 1, 1, NPY_ARRAY_C_CONTIGUOUS, NULL);
1535+
}
1536+
Py_DECREF(values);
1537+
if (result == NULL) {
1538+
return NULL;
1539+
}
1540+
PyArray_CLEARFLAGS((PyArrayObject*)result, NPY_ARRAY_WRITEABLE);
1541+
return result;
1542+
1543+
fail:
1544+
Py_XDECREF(enum_type);
1545+
Py_DECREF(values);
1546+
return NULL;
1547+
}
1548+
14061549
// Fill one strided lane in place: walk positions in the fill direction, carrying
14071550
// the most recent non-target value into each target position (subject to `limit`
14081551
// consecutive fills per run). `elem_base`/`elem_stride` address elements in bytes;

src/methods.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ group_ordering(PyObject *Py_UNUSED(m), PyObject *args, PyObject *kwargs);
7575
PyObject *
7676
group_reduce(PyObject *Py_UNUSED(m), PyObject *args, PyObject *kwargs);
7777

78+
PyObject *
79+
map_object(PyObject *Py_UNUSED(m), PyObject *args, PyObject *kwargs);
80+
7881
PyObject *
7982
fill_directional(PyObject *Py_UNUSED(m), PyObject *args, PyObject *kwargs);
8083

test/test_map_object.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import unittest
2+
from enum import Enum
3+
4+
import numpy as np
5+
from arraykit import map_object
6+
7+
8+
class Color(Enum):
9+
R = 1
10+
G = 2
11+
12+
13+
# reference: prepare_iter_for_array's inference + build, as static_frame applies it
14+
_INEXACT = (float, complex, np.inexact)
15+
_BIG = 1_000_000_000_000_000
16+
17+
18+
def _reference(arr, func):
19+
vals = [func(v) for v in arr]
20+
resolved = None
21+
has_str = has_non = has_inx = has_big = False
22+
for v in vals:
23+
vt = v.__class__
24+
if vt is str or vt is np.str_ or vt is bytes or vt is np.bytes_:
25+
has_str = True
26+
elif hasattr(v, '__len__') or isinstance(v, Enum):
27+
resolved = object
28+
break
29+
else:
30+
has_non = True
31+
if vt in _INEXACT:
32+
has_inx = True
33+
elif vt is int and abs(v) > _BIG:
34+
has_big = True
35+
if (has_str and has_non) or (has_big and has_inx):
36+
resolved = object
37+
break
38+
return np.array(vals) if resolved is None else np.array(vals, dtype=object)
39+
40+
41+
class TestUnit(unittest.TestCase):
42+
def _check(self, arr, func):
43+
post = map_object(arr, func)
44+
exp = _reference(arr, func)
45+
self.assertEqual(post.dtype, exp.dtype, (arr.dtype, exp.dtype))
46+
self.assertTrue(np.array_equal(post, exp))
47+
self.assertFalse(post.flags.writeable)
48+
return post
49+
50+
def test_map_object_str_from_float(self) -> None:
51+
post = self._check(np.array([1.5, 2.25, 3.0]), lambda x: str(x))
52+
self.assertEqual(post.dtype.kind, 'U')
53+
54+
def test_map_object_str_from_bool(self) -> None:
55+
post = self._check(np.array([True, False, True]), lambda x: str(x))
56+
self.assertEqual(post.tolist(), ['True', 'False', 'True'])
57+
self.assertEqual(post.dtype.kind, 'U')
58+
59+
def test_map_object_str_from_int(self) -> None:
60+
self._check(np.array([1, 2, 3], dtype=np.int64), lambda x: str(x))
61+
62+
def test_map_object_native_float(self) -> None:
63+
post = self._check(np.array([1.5, 2.5]), lambda x: float(x) * 2)
64+
self.assertEqual(post.dtype, np.dtype(np.float64))
65+
66+
def test_map_object_native_int(self) -> None:
67+
post = self._check(np.array([1, 2, 3]), lambda x: int(x) + 1)
68+
self.assertEqual(post.dtype, np.dtype(np.int64))
69+
70+
def test_map_object_tuple_result(self) -> None:
71+
post = self._check(np.array([1, 2]), lambda x: (int(x), int(x)))
72+
self.assertEqual(post.dtype, np.dtype(object))
73+
74+
def test_map_object_list_result(self) -> None:
75+
post = self._check(np.array([1, 2]), lambda x: [int(x)])
76+
self.assertEqual(post.dtype, np.dtype(object))
77+
78+
def test_map_object_mixed_str_nonstr(self) -> None:
79+
post = self._check(np.array([1, 2, 3]), lambda x: str(x) if x > 1 else int(x))
80+
self.assertEqual(post.dtype, np.dtype(object))
81+
82+
def test_map_object_python_float(self) -> None:
83+
self._check(np.array([1, 2, 3]), lambda x: 1.5)
84+
85+
def test_map_object_bigint_and_inexact(self) -> None:
86+
# a large python int mixed with a python float -> object
87+
post = self._check(np.array([1, 2]), lambda x: 10**18 if x == 1 else 1.5)
88+
self.assertEqual(post.dtype, np.dtype(object))
89+
90+
def test_map_object_bigint_only(self) -> None:
91+
# big ints alone (no inexact) do not force object
92+
post = self._check(np.array([1, 2]), lambda x: 10**18)
93+
self.assertNotEqual(post.dtype, np.dtype(object))
94+
95+
def test_map_object_enum_result(self) -> None:
96+
post = self._check(np.array([1, 2]), lambda x: Color.R)
97+
self.assertEqual(post.dtype, np.dtype(object))
98+
99+
def test_map_object_object_input(self) -> None:
100+
arr = np.array(['a', 'bb', 'ccc'], dtype=object)
101+
post = self._check(arr, lambda x: len(x))
102+
self.assertEqual(post.tolist(), [1, 2, 3])
103+
104+
def test_map_object_receives_numpy_scalar(self) -> None:
105+
# elements are boxed as numpy scalars, matching Series/array iteration
106+
seen = []
107+
map_object(np.array([1.5, 2.5]), lambda x: seen.append(type(x)) or x)
108+
self.assertTrue(all(t is np.float64 for t in seen))
109+
110+
def test_map_object_str_subclass_is_object(self) -> None:
111+
# a str subclass is not an exact str -> sized object -> object array
112+
class S(str):
113+
pass
114+
115+
post = map_object(np.array([1, 2]), lambda x: S(str(x)))
116+
self.assertEqual(post.dtype, np.dtype(object))
117+
118+
def test_map_object_empty(self) -> None:
119+
post = self._check(np.array([], dtype=np.float64), lambda x: str(x))
120+
self.assertEqual(len(post), 0)
121+
122+
def test_map_object_propagates_exception(self) -> None:
123+
def bad(x):
124+
raise ValueError('boom')
125+
126+
with self.assertRaises(ValueError):
127+
map_object(np.array([1, 2]), bad)
128+
129+
def test_map_object_errors(self) -> None:
130+
with self.assertRaises(ValueError): # 2d
131+
map_object(np.array([[1, 2]]), lambda x: x)
132+
with self.assertRaises(TypeError): # not callable
133+
map_object(np.array([1, 2]), 3)
134+
with self.assertRaises(TypeError): # not an array
135+
map_object([1, 2], lambda x: x)
136+
137+
138+
if __name__ == '__main__':
139+
unittest.main()

0 commit comments

Comments
 (0)