Skip to content

Commit 7da9984

Browse files
committed
Fix torch/PyTorch introspection compatibility
Add C-side callback name registry so erlang module's __getattr__ only returns ErlangFunction wrappers for registered callbacks, not arbitrary attributes. Fixes "erlang.Function has no attribute 'endswith'" error. Bump version to 1.3.2
1 parent ee93dec commit 7da9984

File tree

7 files changed

+337
-7
lines changed

7 files changed

+337
-7
lines changed

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# Changelog
22

3+
## 1.3.2 (2026-02-17)
4+
5+
### Fixed
6+
7+
- **torch/PyTorch introspection compatibility** - Fixed `AttributeError: 'erlang.Function'
8+
object has no attribute 'endswith'` when importing torch or sentence_transformers in
9+
contexts where erlang_python callbacks are registered.
10+
- Root cause: torch does dynamic introspection during import, iterating through Python's
11+
namespace and calling `.endswith()` on objects. The `erlang` module's `__getattr__` was
12+
returning `ErlangFunction` wrappers for *any* attribute access.
13+
- Solution: Added C-side callback name registry. Now `__getattr__` only returns
14+
`ErlangFunction` wrappers for actually registered callbacks. Unregistered attributes
15+
raise `AttributeError` (normal Python behavior).
16+
- New test: `test_callback_name_registry` in `py_reentrant_SUITE.erl`
17+
318
## 1.3.1 (2026-02-16)
419

520
### Fixed

c_src/py_callback.c

Lines changed: 252 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,169 @@
8383
* @note This file is included from py_nif.c (single compilation unit)
8484
*/
8585

86+
/* ============================================================================
87+
* Callback Name Registry
88+
*
89+
* Maintains a C-side registry of registered callback function names.
90+
* This allows erlang_module_getattr to only return ErlangFunction wrappers
91+
* for actually registered functions, preventing introspection issues with
92+
* libraries like torch that probe module attributes.
93+
* ============================================================================ */
94+
95+
/**
96+
* @def CALLBACK_REGISTRY_BUCKETS
97+
* @brief Number of hash buckets for the callback registry
98+
*/
99+
#define CALLBACK_REGISTRY_BUCKETS 64
100+
101+
/**
102+
* @struct callback_name_entry_t
103+
* @brief Entry in the callback name registry hash table
104+
*/
105+
typedef struct callback_name_entry {
106+
char *name; /**< Callback name (owned) */
107+
size_t name_len; /**< Length of name */
108+
struct callback_name_entry *next; /**< Next entry in bucket chain */
109+
} callback_name_entry_t;
110+
111+
/** @brief Hash table buckets for callback registry */
112+
static callback_name_entry_t *g_callback_registry[CALLBACK_REGISTRY_BUCKETS] = {NULL};
113+
114+
/** @brief Mutex protecting the callback registry */
115+
static pthread_mutex_t g_callback_registry_mutex = PTHREAD_MUTEX_INITIALIZER;
116+
117+
/**
118+
* @brief Simple hash function for callback names
119+
*/
120+
static unsigned int callback_name_hash(const char *name, size_t len) {
121+
unsigned int hash = 5381;
122+
for (size_t i = 0; i < len; i++) {
123+
hash = ((hash << 5) + hash) + (unsigned char)name[i];
124+
}
125+
return hash % CALLBACK_REGISTRY_BUCKETS;
126+
}
127+
128+
/**
129+
* @brief Check if a callback name is registered
130+
*
131+
* Thread-safe lookup in the callback registry.
132+
*
133+
* @param name Callback name to check
134+
* @param len Length of name
135+
* @return true if registered, false otherwise
136+
*/
137+
static bool is_callback_registered(const char *name, size_t len) {
138+
unsigned int bucket = callback_name_hash(name, len);
139+
bool found = false;
140+
141+
pthread_mutex_lock(&g_callback_registry_mutex);
142+
143+
callback_name_entry_t *entry = g_callback_registry[bucket];
144+
while (entry != NULL) {
145+
if (entry->name_len == len && memcmp(entry->name, name, len) == 0) {
146+
found = true;
147+
break;
148+
}
149+
entry = entry->next;
150+
}
151+
152+
pthread_mutex_unlock(&g_callback_registry_mutex);
153+
return found;
154+
}
155+
156+
/**
157+
* @brief Register a callback name
158+
*
159+
* Thread-safe addition to the callback registry.
160+
*
161+
* @param name Callback name to register
162+
* @param len Length of name
163+
* @return 0 on success, -1 on failure
164+
*/
165+
static int register_callback_name(const char *name, size_t len) {
166+
/* Check if already registered */
167+
if (is_callback_registered(name, len)) {
168+
return 0; /* Already registered, success */
169+
}
170+
171+
/* Allocate new entry */
172+
callback_name_entry_t *entry = enif_alloc(sizeof(callback_name_entry_t));
173+
if (entry == NULL) {
174+
return -1;
175+
}
176+
177+
entry->name = enif_alloc(len + 1);
178+
if (entry->name == NULL) {
179+
enif_free(entry);
180+
return -1;
181+
}
182+
183+
memcpy(entry->name, name, len);
184+
entry->name[len] = '\0';
185+
entry->name_len = len;
186+
187+
unsigned int bucket = callback_name_hash(name, len);
188+
189+
pthread_mutex_lock(&g_callback_registry_mutex);
190+
191+
entry->next = g_callback_registry[bucket];
192+
g_callback_registry[bucket] = entry;
193+
194+
pthread_mutex_unlock(&g_callback_registry_mutex);
195+
196+
return 0;
197+
}
198+
199+
/**
200+
* @brief Unregister a callback name
201+
*
202+
* Thread-safe removal from the callback registry.
203+
*
204+
* @param name Callback name to unregister
205+
* @param len Length of name
206+
*/
207+
static void unregister_callback_name(const char *name, size_t len) {
208+
unsigned int bucket = callback_name_hash(name, len);
209+
210+
pthread_mutex_lock(&g_callback_registry_mutex);
211+
212+
callback_name_entry_t **pp = &g_callback_registry[bucket];
213+
while (*pp != NULL) {
214+
callback_name_entry_t *entry = *pp;
215+
if (entry->name_len == len && memcmp(entry->name, name, len) == 0) {
216+
*pp = entry->next;
217+
enif_free(entry->name);
218+
enif_free(entry);
219+
break;
220+
}
221+
pp = &entry->next;
222+
}
223+
224+
pthread_mutex_unlock(&g_callback_registry_mutex);
225+
}
226+
227+
/**
228+
* @brief Clean up the callback registry
229+
*
230+
* Frees all entries. Called during NIF unload.
231+
*/
232+
static void cleanup_callback_registry(void) {
233+
pthread_mutex_lock(&g_callback_registry_mutex);
234+
235+
for (int i = 0; i < CALLBACK_REGISTRY_BUCKETS; i++) {
236+
callback_name_entry_t *entry = g_callback_registry[i];
237+
while (entry != NULL) {
238+
callback_name_entry_t *next = entry->next;
239+
enif_free(entry->name);
240+
enif_free(entry);
241+
entry = next;
242+
}
243+
g_callback_registry[i] = NULL;
244+
}
245+
246+
pthread_mutex_unlock(&g_callback_registry_mutex);
247+
}
248+
86249
/* ============================================================================
87250
* Suspended state management
88251
* ============================================================================ */
@@ -1061,10 +1224,29 @@ static PyObject *ErlangFunction_call(ErlangFunctionObject *self, PyObject *args,
10611224

10621225
/**
10631226
* Module __getattr__ - enables "from erlang import func_name" and "erlang.func_name()"
1227+
*
1228+
* Only returns ErlangFunction wrapper for REGISTERED callback names.
1229+
* This prevents torch and other libraries that introspect module attributes
1230+
* from getting callable objects for arbitrary attribute names.
10641231
*/
10651232
static PyObject *erlang_module_getattr(PyObject *module, PyObject *name) {
10661233
(void)module; /* Unused */
1067-
/* Return an ErlangFunction wrapper for any attribute access */
1234+
1235+
/* Get the name as a C string */
1236+
const char *name_str = PyUnicode_AsUTF8(name);
1237+
if (name_str == NULL) {
1238+
return NULL; /* Exception already set */
1239+
}
1240+
size_t name_len = strlen(name_str);
1241+
1242+
/* Check if this callback is registered */
1243+
if (!is_callback_registered(name_str, name_len)) {
1244+
PyErr_Format(PyExc_AttributeError,
1245+
"module 'erlang' has no attribute '%s'", name_str);
1246+
return NULL;
1247+
}
1248+
1249+
/* Return an ErlangFunction wrapper for registered callbacks */
10681250
return ErlangFunction_New(name);
10691251
}
10701252

@@ -1716,3 +1898,72 @@ static ERL_NIF_TERM nif_resume_callback_dirty(ErlNifEnv *env, int argc, const ER
17161898

17171899
return result;
17181900
}
1901+
1902+
/* ============================================================================
1903+
* NIF functions for callback name registration
1904+
* ============================================================================ */
1905+
1906+
/**
1907+
* @brief NIF to register a callback name in the C-side registry
1908+
*
1909+
* This allows the erlang module's __getattr__ to return ErlangFunction
1910+
* wrappers only for registered callbacks, preventing introspection issues.
1911+
*
1912+
* Args: Name (binary or atom)
1913+
* Returns: ok | {error, Reason}
1914+
*/
1915+
static ERL_NIF_TERM nif_register_callback_name(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) {
1916+
(void)argc;
1917+
1918+
ErlNifBinary name_bin;
1919+
char atom_buf[256];
1920+
1921+
const char *name;
1922+
size_t name_len;
1923+
1924+
if (enif_inspect_binary(env, argv[0], &name_bin)) {
1925+
name = (const char *)name_bin.data;
1926+
name_len = name_bin.size;
1927+
} else if (enif_get_atom(env, argv[0], atom_buf, sizeof(atom_buf), ERL_NIF_LATIN1)) {
1928+
name = atom_buf;
1929+
name_len = strlen(atom_buf);
1930+
} else {
1931+
return make_error(env, "invalid_name");
1932+
}
1933+
1934+
if (register_callback_name(name, name_len) < 0) {
1935+
return make_error(env, "registration_failed");
1936+
}
1937+
1938+
return ATOM_OK;
1939+
}
1940+
1941+
/**
1942+
* @brief NIF to unregister a callback name from the C-side registry
1943+
*
1944+
* Args: Name (binary or atom)
1945+
* Returns: ok
1946+
*/
1947+
static ERL_NIF_TERM nif_unregister_callback_name(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) {
1948+
(void)argc;
1949+
1950+
ErlNifBinary name_bin;
1951+
char atom_buf[256];
1952+
1953+
const char *name;
1954+
size_t name_len;
1955+
1956+
if (enif_inspect_binary(env, argv[0], &name_bin)) {
1957+
name = (const char *)name_bin.data;
1958+
name_len = name_bin.size;
1959+
} else if (enif_get_atom(env, argv[0], atom_buf, sizeof(atom_buf), ERL_NIF_LATIN1)) {
1960+
name = atom_buf;
1961+
name_len = strlen(atom_buf);
1962+
} else {
1963+
return make_error(env, "invalid_name");
1964+
}
1965+
1966+
unregister_callback_name(name, name_len);
1967+
1968+
return ATOM_OK;
1969+
}

c_src/py_nif.c

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1708,7 +1708,9 @@ static int upgrade(ErlNifEnv *env, void **priv_data, void **old_priv_data,
17081708
static void unload(ErlNifEnv *env, void *priv_data) {
17091709
(void)env;
17101710
(void)priv_data;
1711-
/* Cleanup handled by finalize */
1711+
/* Clean up callback name registry */
1712+
cleanup_callback_registry();
1713+
/* Other cleanup handled by finalize */
17121714
}
17131715

17141716
static ErlNifFunc nif_funcs[] = {
@@ -1776,7 +1778,11 @@ static ErlNifFunc nif_funcs[] = {
17761778
{"thread_worker_signal_ready", 1, nif_thread_worker_signal_ready, 0},
17771779

17781780
/* Async callback support (for erlang.async_call) */
1779-
{"async_callback_response", 3, nif_async_callback_response, 0}
1781+
{"async_callback_response", 3, nif_async_callback_response, 0},
1782+
1783+
/* Callback name registry (prevents torch introspection issues) */
1784+
{"register_callback_name", 1, nif_register_callback_name, 0},
1785+
{"unregister_callback_name", 1, nif_unregister_callback_name, 0}
17801786
};
17811787

17821788
ERL_NIF_INIT(py_nif, nif_funcs, load, NULL, upgrade, unload)

src/erlang_python.app.src

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{application, erlang_python, [
22
{description, "Execute Python applications from Erlang using dirty NIFs"},
3-
{vsn, "1.3.1"},
3+
{vsn, "1.3.2"},
44
{registered, [py_pool]},
55
{mod, {erlang_python_app, []}},
66
{applications, [

src/py_callback.erl

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,17 +62,24 @@ init_tab() ->
6262
ok.
6363

6464
%% @doc Register a function to be callable from Python.
65+
%% Also registers the name in the C-side registry so Python's erlang module
66+
%% __getattr__ will return an ErlangFunction wrapper for this name.
6567
-spec register(Name :: atom() | binary(), Fun :: fun((list()) -> term()) | {atom(), atom()}) -> ok.
6668
register(Name, Fun) ->
6769
NameBin = to_binary(Name),
6870
ets:insert(?TABLE, {NameBin, Fun}),
71+
%% Register name in C-side registry for Python __getattr__
72+
py_nif:register_callback_name(NameBin),
6973
ok.
7074

7175
%% @doc Unregister a function.
76+
%% Also unregisters the name from the C-side registry.
7277
-spec unregister(Name :: atom() | binary()) -> ok.
7378
unregister(Name) ->
7479
NameBin = to_binary(Name),
7580
ets:delete(?TABLE, NameBin),
81+
%% Unregister name from C-side registry
82+
py_nif:unregister_callback_name(NameBin),
7683
ok.
7784

7885
%% @doc Lookup a registered function.

src/py_nif.erl

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,10 @@
6565
thread_worker_write/2,
6666
thread_worker_signal_ready/1,
6767
%% Async callback support (for erlang.async_call)
68-
async_callback_response/3
68+
async_callback_response/3,
69+
%% Callback name registry (prevents torch introspection issues)
70+
register_callback_name/1,
71+
unregister_callback_name/1
6972
]).
7073

7174
-on_load(load_nif/0).
@@ -401,3 +404,20 @@ thread_worker_signal_ready(_Fd) ->
401404
ok | {error, term()}.
402405
async_callback_response(_Fd, _CallbackId, _Response) ->
403406
?NIF_STUB.
407+
408+
%%% ============================================================================
409+
%%% Callback Name Registry
410+
%%% ============================================================================
411+
412+
%% @doc Register a callback name in the C-side registry.
413+
%% This allows the Python erlang module's __getattr__ to return
414+
%% ErlangFunction wrappers only for registered callbacks, preventing
415+
%% introspection issues with libraries like torch.
416+
-spec register_callback_name(atom() | binary()) -> ok | {error, term()}.
417+
register_callback_name(_Name) ->
418+
?NIF_STUB.
419+
420+
%% @doc Unregister a callback name from the C-side registry.
421+
-spec unregister_callback_name(atom() | binary()) -> ok.
422+
unregister_callback_name(_Name) ->
423+
?NIF_STUB.

0 commit comments

Comments
 (0)