Thanks for the suggestions. One other constraint I didn't originally mention (in an effort to simplify the repro case) is that I would like for the 'utility' module to be usable by any number of different 'client' modules, so I want to avoid Cythonizing the client and the utility into one Extension together (because I think ultimately, this would necessitate creating one huge Extension for the whole application).
A) Before Cythonize:
* If p/m_stub.pyx exists, ensure that p/m.pyx and p/m.pxd also exist (m.pyx is typically empty), and also ensure that p/m.pxd contains at least one "cdef extern from" statement.
* Copy p/m.pyx and p/m.pxd to backup locations (in case something unexpected happens, and also to use for restoring the original state after Cythonizing).
* Move p/m_stub.pyx to p/m.pyx, overwriting anything that might have been there before.
* For every line of the form "cdef extern blah blah:" in p/m.pxd, replace it with something like "cdef: # extern blah blah", i.e. comment out the extern part. Doing it this way, the file could potentially be reverted in-place (with a sufficiently obscure token in the comment separation) and then verified against the backup afterwards. Alternatively, you could just put "cdef:" if the backup/original will always be used to clobber the modified version afterwards. I'm also currently assuming that the declarations always happen with "cdef ...:" and then the function signatures on additional lines following that, but a more robust solution might be required in cases where extern functions can be declared on one line.
B) Cythonize with all of the .pyx files in the various utility and client directories together (no explicit references to Extension required, for my case at least). I'm also using exclude=**/*_stub.pyx, but this may not be necessary depending on how good the file shifting logic is.
C) After Cythonize:
* Move p/m.pyx back to p/m_stub.pyx, and then move the backups of the non-stub .pxd and .pyx files back to their original locations (m.pxd and m.pyx).
Then, setup() can be called with ext_modules=<the list of Extensions generated in step B>. This seems to successfully create a module for each utility and each client, as desired.
While definitely brittle, one advantage of this approach vs. 2) is that it doesn't require a .c bridge file, so it's easy to just write a stub in Cython. Also, the compile will fail if the stub definition file doesn't satisfy the signatures specified in the declaration file, which is good. Compiling and running in this stub mode won't highlight if the declaration file doesn't match the specified header file, but one can still perform a compile in 'non-stub' mode (i.e. not doing any of the above file manipulation) and any problems with the declarations should then appear. This method just requires that developers on the project learn the pattern of required files for including 'stub' implementations, and of course the legwork to actually implement the file tomfoolery.
For the Stack Overflow issue referenced above, in the alternate case of Cythonizing/compiling in non-stub mode, I use a similar back-up-and-modify strategy to replace a placeholder in the header paths (in .pxd files only) with an actual, resolved directory path on the machine doing the compiling. In effect, this lets the developer use a relative path when specifying header files in a way that won't barf with multiple development environments, since by the time it gets Cythonized and compiled it's actually using an absolute path. I didn't see any responses to the OP on that Stack Overflow issue, but it would be awesome if a future version could include some kind of functionality for either variable substitution or relative paths in "extern from" declarations... the stub vs. non-stub issue is probably less broadly applicable and/or easy to incorporate.
Thanks again,
Daniel