[備忘録] GAE の dev_appserver で ctypes が import 出来ずはまった

小一時間悩んだので備忘録として残しておきます。

# 結論

ちゃんと環境でサポートされているバージョンを使いましょう。(`numpy` なら `1.6.1`)
https://cloud.google.com/appengine/docs/python/tools/built-in-libraries-27

あと `dev_appserver.py` の仕組みに少し詳しくなれました。

# 現象

サポートされているバージョンが `1.6.1` なのはさておき、Docker で構築中の GAE python の開発環境で `numpy` を `1.9.1` から `1.10.1` に上げたら以下のエラーが出るようになって困りました。

```
  File "/usr/local/google_appengine/google/appengine/tools/devappserver2/python/sandbox.py", line 705, in load_module
    module = self._find_and_load_module(fullname, fullname, [module_path])
  File "/usr/local/google_appengine/google/appengine/tools/devappserver2/python/sandbox.py", line 446, in _find_and_load_module
    return imp.load_module(fullname, source_file, path_name, description)
  File "/root/.pyenv/versions/2.7.12/lib/python2.7/site-packages/numpy/__init__.py", line 180, in <module>
    from . import add_newdocs
  ...
  File "/root/.pyenv/versions/2.7.12/lib/python2.7/site-packages/numpy/core/_internal.py", line 14, in <module>
    import ctypes
  File "/root/.pyenv/versions/2.7.12/lib/python2.7/ctypes/__init__.py", line 7, in <module>
    from _ctypes import Union, Structure, Array
  File "/usr/local/google_appengine/google/appengine/tools/devappserver2/python/sandbox.py", line 964, in load_module
    raise ImportError('No module named %s' % fullname)
ImportError: No module named _ctypes
```

# 原因

GAE python では主に[セキュリティ的な理由](https://cloud.google.com/appengine/kb/?csw=1#libraries)で `C` で書かれた python module の大部分は利用することができません。

local 開発環境で利用する `dev_appserver.py` でもこの制限を模擬するために(そうでないと local では動くのにデプロイしたら動かなくなる謎現象に悩まされるので)、上のエラーにも出てくる `sandbox.py` というモジュールが使われています。(このモジュール以外にもこういった制限を模擬するものがきっとあると思いますがそこまでは調べていないです)

具体的には `sandbox.py` の以下のコードで `import` が呼ばれた場合の hook を登録しています。

`${GAE_HOME}/google/appengine/tools/devappserver2/python/sandbox.py`

```py
def _install_import_hooks(config, path_override_hook):
  """Install runtime's import hooks.

  These hooks customize the import process as per
  https://docs.python.org/2/library/sys.html#sys.meta_path .

  Args:
    config: An apphosting/tools/devappserver2/runtime_config.proto
        for this instance.
    path_override_hook: A hook for importing special appengine
        versions of select libraries from the libraries
        section of the current module's app.yaml file.
  """
  if not config.vm:
    enabled_library_regexes = [
        NAME_TO_CMODULE_WHITELIST_REGEX[lib.name] for lib in config.libraries
        if lib.name in NAME_TO_CMODULE_WHITELIST_REGEX]
    sys.meta_path = [
        StubModuleImportHook(),
        ModuleOverrideImportHook(_MODULE_OVERRIDE_POLICIES),
        CModuleImportHook(enabled_library_regexes),
        path_override_hook,
        PyCryptoRandomImportHook,
        PathRestrictingImportHook(enabled_library_regexes)]
  else:
    ...
```

`sys.meta_path` というのがこの [hook を登録する場所にあたり](https://docs.python.org/2.7/library/sys.html#sys.meta_path)、
[PEP 302 -- New Import Hooks](https://www.python.org/dev/peps/pep-0302/#specification-part-1-the-importer-protocol) で定義された `find_module(fullname, path=None)` というメソッドを持つオブジェクトが登録されます。

この `find_module` で `module` が発見された場合には `loader` が返され、その `load_module(fullname)` が呼ばれることで `import` が行われます。

注目すべきは `CModuleImportHook` で `sandbox.py` をさらに見ていくとクラス定義を見つけることができます。

```py
class CModuleImportHook(object):
  """An import hook implementing a C module (builtin or extensions) whitelist.

  CModuleImportHook implements the PEP 302 finder protocol where it returns
  itself as a loader for any builtin module that isn't whitelisted or part of an
  enabled third-party library. The loader implementation always raises
  ImportError.
  """

  def __init__(self, enabled_regexes):
    self._enabled_regexes = enabled_regexes

  @staticmethod
  def _module_type(fullname, path):
    _, _, submodule_name = fullname.rpartition('.')
    try:
      f, _, description = imp.find_module(submodule_name, path)
      _, _, file_type = description
    except ImportError:
      return None
    if f:
      f.close()
    return file_type

  def find_module(self, fullname, path=None):
    if (fullname in _WHITE_LIST_C_MODULES or
        any(regex.match(fullname) for regex in self._enabled_regexes)):
      return None
    if self._module_type(fullname, path) in [imp.C_EXTENSION, imp.C_BUILTIN]:
      return self
    return None

  def load_module(self, fullname):
    raise ImportError('No module named %s' % fullname)
```

このクラスは `find_module` と `load_module` の 2 つのメソッドを持っています。 `find_module` で `C` の native module だと判定すると `self` が返り、 `self.load_module()` が必ず `ImportError` を `raise` するため `import` に失敗していました。

※`_WHITE_LIST_C_MODULES` など幾つか例外は登録されていますが `_ctypes` は例外扱いされていません

`numpy` の `1.9.1` は `ctypes` を import する際に `ImportError` が出るとフォールバックしてくれていたのですが、 `1.10.1` に挙げるとその挙動が無くなりこけるようになったという次第でした。