LibreOffice5(120)ドキュメントに埋め込んだモジュールをインポートする

2018-01-12

旧ブログ

t f B! P L
LibreOffice5(116)埋め込みマクロからPythonモジュールをロードする方法ではうまくできなかった、import文でドキュメントに埋め込んだモジュールをインポートする方法がうまくいきました。

前の関連記事:LibreOffice5(119)コンテナウィンドウとコンポーネントウィンドウのサービスとインターフェイスの比較

(2018.9.12追記。撤廃メソッドを置換しました。LibreOffice5(156)ドキュメントに埋め込んだモジュールをインポートする 撤廃メソッドの排除参照。)

vnd.sun.star.tdocプロトコールからのimportを有効にするtdocimport.py


tdocimport.py
import sys
import importlib.abc
from types import ModuleType
def _get_links(simplefileaccess, url):  # url内のファイル名とフォルダ名のリストを返す関数。
 foldercontents = simplefileaccess.getFolderContents(url, True)  # url内のファイルとフォルダをすべて取得。フルパスで返ってくる。
 tdocpath = "".join((url, "/"))  # 除去するパスの部分を作成。
 return [content.replace(tdocpath, "") for content in foldercontents]  # ファイル名かフォルダ名だけのリストにして返す。
class UrlMetaFinder(importlib.abc.MetaPathFinder):  # meta path finderの実装。
 def __init__(self, simplefileaccess, baseurl):
  self._simplefileaccess = simplefileaccess  # LibreOfficeドキュメント内のファイルにアクセスするためのsimplefileaccess
  self._baseurl = baseurl  # モジュールを探すパス
  self._links   = {}  # baseurl内のファイル名とフォルダ名のリストのキャッシュにする辞書。。
  self._loaders = {baseurl: UrlModuleLoader(simplefileaccess, baseurl)}  # ローダーのキャッシュにする辞書。
 def find_module(self, fullname, path=None):  # find_moduleはPython3.4で撤廃だが、find_spec()にしてもそのままではうまく動かない。
  if path is None:
   baseurl = self._baseurl
  else:
   if not path[0].startswith(self._baseurl):
    return None
   baseurl = path[0]
  parts = fullname.split('.')
  basename = parts[-1]
  if basename not in self._links:  # Check link cache
   self._links[baseurl] = _get_links(self._simplefileaccess, baseurl)
  if basename in self._links[baseurl]:  # Check if it's a package。パッケージの時。
   fullurl = "/".join((self._baseurl, basename))
   loader = UrlPackageLoader(self._simplefileaccess, fullurl)
   try:  # Attempt to load the package (which accesses __init__.py)
    loader.load_module(fullname)
    self._links[fullurl] = _get_links(self._simplefileaccess, fullurl)
    self._loaders[fullurl] = UrlModuleLoader(self._simplefileaccess, fullurl)
   except ImportError:
    loader = None
   return loader
  filename = "".join((basename, '.py'))
  if filename in self._links[baseurl]:  # A normal module
   return self._loaders[baseurl]
  else:
   return None
 def invalidate_caches(self):
  self._links.clear()
class UrlModuleLoader(importlib.abc.SourceLoader):  # Module Loader for a URL
 def __init__(self, simplefileaccess, baseurl):
  self._simplefileaccess = simplefileaccess  # LibreOfficeドキュメント内のファイルにアクセスするためのsimplefileaccess
  self._baseurl = baseurl  # モジュールを探すパス
  self._source_cache = {}  # ソースのキャッシュの辞書。
 def module_repr(self, module):  # モジュールを表す文字列を返す。
  return '<urlmodule {} from {}>'.format(module.__name__, module.__file__)
 def load_module(self, fullname):  # Required method。引数はimport文で使うフルネーム。
  code = self.get_code(fullname)  # モジュールのコードオブジェクトを取得。
  mod = sys.modules.setdefault(fullname, ModuleType(fullname))  # 辞書sys.modulesにキーfullnameなければ値を代入して値を取得。
  mod.__file__ = self.get_filename(fullname)  # ソースファイルへのフルパスを取得。
  mod.__loader__ = self  # ローダーを取得。
  mod.__package__ = fullname.rpartition('.')[0]  # パッケージ名を取得。.区切りがないときは空文字が入る。
  exec(code, mod.__dict__)  # コードオブジェクトを実行する。
  return mod  # モジュールオブジェクトを返す。
 def get_code(self, fullname):  # モジュールのコードオブジェクトを返す。Optional extensions。引数はimport文で使うフルネーム。
  src = self.get_source(fullname)
  return compile(src, self.get_filename(fullname), 'exec')
 def get_data(self, path):  # バイナリ文字列を返す。
  pass
 def get_filename(self, fullname):  # ソースファイルへのフルパスを返す。引数はimport文で使うフルネーム。
  return "".join((self._baseurl, '/', fullname.split('.')[-1], '.py'))
 def get_source(self, fullname):  # モジュールのソースをテキストで返す。
  filename = self.get_filename(fullname)  # ソースファイルへのフルパス。
  if filename in self._source_cache:  # すでにキャッシュがあればそれを返して終わる。
   return self._source_cache[filename]
  try:
   inputstream = self._simplefileaccess.openFileRead(filename)  # ソースファイルのインプットストリームを取得。
   dummy, b = inputstream.readBytes([], inputstream.available())  # simplefileaccess.getSize(module_tdocurl)は0が返る。
   source = bytes(b).decode("utf-8")  # モジュールのソースファイルをutf-8のテキストで取得。
   self._source_cache[filename] = source  # ソースをキャッシュに取得。
   return source  # ソースのテキストを返す。
  except:
   raise ImportError("Can't load {}".format(filename))
 def is_package(self, fullname):  # パッケージの時はTrueを返す。
  return False
class UrlPackageLoader(UrlModuleLoader):  # Package loader for a URL
 def load_module(self, fullname):  # fullnameはパッケージのときはフォルダ名に該当する。
  mod = super().load_module(fullname)  # __init__.pyを実行する。
  mod.__path__ = [self._baseurl]  # パッケージ内の検索パスを指定する文字列のリスト
  mod.__package__ = fullname  # フォルダ名を入れる。
 def get_filename(self, fullname):  # パッケージの__init__.pyを返す。
  return "/".join((self._baseurl, '__init__.py'))
 def is_package(self, fullname):  # パッケージの時はTrueを返す。
  return True
_installed_meta_cache = {}  # meta path finderを入れておくグローバル辞書。重複を防ぐ目的。
def install_meta(simplefileaccess, address):  # Utility functions for installing the loader
 if address not in _installed_meta_cache:  # グローバル辞書にないパスの時。
  finder = UrlMetaFinder(simplefileaccess, address)  # meta path finder。モジュールを探すクラスをインスタンス化。
  _installed_meta_cache[address] = finder  # グローバル辞書にmeta path finderを登録。
  sys.meta_path.append(finder)  # meta path finderをsys.meta_pathに登録。
def remove_meta(address):  # Utility functions for uninstalling the loader
 if address in _installed_meta_cache:
  finder = _installed_meta_cache.pop(address)
  sys.meta_path.remove(finder)
Python Cookbook10.11.loading_modules_from_a_remote_machine_using_import_hooksurlimport.pyを改変したものです。

ドキュメントに埋め込んだファイルを読み込むために、20行目でまずSimpleFileAccessのインスタンスを受け取らないといけません。

Python3.3から3.5にかけてimport文周りのモジュールの変更があったようで、tdocimport.pyで使っているメソッドにはdeprecatedになっているものがいくつかあります。

14行目のimportlib.abc.MetaPathFinderのfind_module()メソッドはPython3.4でfind_spec()メソッドに、49行目のimportlib.abc.SourceLoaderのload_module()メソッドはPython3.4でexec_module()メソッドに、置換することが推奨されていますが、どうもうまくいきませんでした。

pydevdでうまくブレークできないところが多いので理解がとても難しいです。

とくにパッケージのインポートの処理がうまくできませんでした。

とりあえずdeprecatedのメソッドを使ったままLibreOffice5.4では動きました。

tdocimport.pyを使った埋め込みマクロ


tdocimport.ods

tdocimport.pyを使った埋め込みマクロを埋め込んだCalcドキュメントの例です。
└── Scripts
   └── python
       ├── embeddedmacro.py
       └── pythonpath
           ├── consts.py
           ├── subdir
           │   ├── __init__.py
           │   ├── consts2.py
           │   └── submod.py
           └── tdocimport.py
ドキュメント内のScripts/pythonフォルダの構造はこのようになっています。


マクロを有効化してtdocimport.odsを開いて、マクロセレクターでembeddedmacroのmacroを実行するとA1セルとA4セルに文字列が入力されます。

pythonpathフォルダはマクロセレクターからはみえません。

embeddedmacro.py
import unohelper  # オートメーションには必須(必須なのはuno)。
import sys
from types import ModuleType
global XSCRIPTCONTEXT  # PyDevのエラー抑制用。
def macro(documentevent=None):  # 引数は文書のイベント駆動用。  
 doc = XSCRIPTCONTEXT.getDocument() if documentevent is None else documentevent.Source  # ドキュメントのモデルを取得。 
 controller = doc.getCurrentController()  # コントローラの取得。
 sheet = controller.getActiveSheet()
 ctx = XSCRIPTCONTEXT.getComponentContext()  # コンポーネントコンテクストの取得。
 smgr = ctx.getServiceManager()  # サービスマネージャーの取得。
 simplefileaccess = smgr.createInstanceWithContext("com.sun.star.ucb.SimpleFileAccess", ctx)  # SimpleFileAccess
 modulefolderpath = getModuleFolderPath(ctx, smgr, doc)  # 埋め込みpythonpathフォルダのパスを取得。
 tdocimport = load_module(simplefileaccess, "/".join((modulefolderpath, "tdocimport.py")))  # import hooks
 tdocimport.install_meta(simplefileaccess, modulefolderpath)
 import consts
 from subdir import submod
 sheet["A1"].setString(consts.LISTSHEET["name"])
 submod.fromsubmod(sheet)
 tdocimport.remove_meta(modulefolderpath)
def load_module(simplefileaccess, modulepath):
 inputstream = simplefileaccess.openFileRead(modulepath)
 dummy, b = inputstream.readBytes([], inputstream.available())  # simplefileaccess.getSize(module_tdocurl)は0が返る。
 source = bytes(b).decode("utf-8")  # モジュールのソースをテキストで取得。
 mod = sys.modules.setdefault(modulepath, ModuleType(modulepath))  # 新規モジュールをsys.modulesに挿入。
 code = compile(source, modulepath, 'exec')  # urlを呼び出し元としてソースコードをコンパイルする。
 mod.__file__ = modulepath  # モジュールの__file__を設定。
 mod.__package__ = ''  # モジュールの__package__を設定。
 exec(code, mod.__dict__)  # モジュールの名前空間を設定する。
 return mod
def getModuleFolderPath(ctx, smgr, doc):
 transientdocumentsdocumentcontentfactory = smgr.createInstanceWithContext("com.sun.star.frame.TransientDocumentsDocumentContentFactory", ctx)
 transientdocumentsdocumentcontent = transientdocumentsdocumentcontentfactory.createDocumentContent(doc)
 tdocurl = transientdocumentsdocumentcontent.getIdentifier().getContentIdentifier()  # ex. vnd.sun.star.tdoc:/1 
 return "/".join((tdocurl, "Scripts/python/pythonpath"))  # 開いているドキュメント内の埋め込みマクロフォルダへのパス。
g_exportedScripts = macro, #マクロセレクターに限定表示させる関数をタプルで指定。
embeddedmacro.pyのmacro()を実行すると、13行目でLibreOffice5(116)埋め込みマクロからPythonモジュールをロードする方法の方法で、pythonpath/tdocimport.pyを読み込んでいます。

次の行でドキュメント内のpythonpathフォルダにあるモジュールをimportできるように設定しています。

そこでSimpleFileAccessのインスタンスとドキュメント内のpythonpathフォルダへのパスを渡しています。

16行目はimport subdir.submodでも呼び込めます。

いずれにせよsubdirには__init__.pyファイルがないとエラーになります(つまり名前空間パッケージには未対応)。

submod.pyからは相対インポートでconsts2.pyをインポートしています。

読み込んだモジュールではグローバル変数のXSCRIPTCONTEXTは使えないのでモジュールに渡しておかないといけません。

埋め込みモジュールを編集した後はLibreOfficeをいったん終了させる


Calc(47)埋め込みマクロのためのPyDevプロジェクトを使って動作確認をしていましたが、replaceEmbeddedScripts.pyでドキュメントを開き直すだけでは、インポートしたモジュールのキャッシュが使われていることに気が付きました。

モジュールの作成をしている間の動作確認はLibreOfficeのプロセスを終了してあることを確認してから、開き直さないと、編集前のモジュールが使われている場合があります。

(2018.9.11追記。ドキュメントに埋め込んだとしてもこの方法でインポートしたモジュールはドキュメントごとに区別されているわけではないので、pythonpath以下のパスが同じ埋め込みマクロをもつドキュメントを複数開くと(おそらく)最初に開いたドキュメントのマクロが呼ばれてしまいます。なので、同じパスの埋め込みマクロがあるドキュメントは同時に開かないように気をつけないといけません。)

参考にしたサイト


5. インポートシステム — Python 3.6.4 ドキュメント
カスタムインポートの解説がありますが、理解は難しいです。

Python を支える技術: モジュール・ インポートシステム編 / python-module-import-system - SSSSLIDE
Pythonのインポートシステムの変化の履歴がわかりやすく解説してあるスライドです。

次の関連記事:LibreOffice5(121)SystemClipboardのサービスとインターフェイスの一覧

ブログ検索 by Blogger

Translate

最近のコメント

Created by Calendar Gadget

QooQ