Python for .NET の薄っぺらい紹介

Python 用 lex/yacc の話のなかでしれーっと登場させちゃったが、せっかくなので。

Python for .NET の紹介

の前に

ワタシもかつてそうだったのでわかるのだが、「Microsoft がなんとなしに嫌い」という人々は結構多いと思う。単なる感情的な要因からのものもあるだろうし、エンジニアにとっては、「他の OS の主流から外れ過ぎていていやな記憶しかない」ということもあるだろう。

ただその好き嫌いも、「客観的な評価」を一度もしたことがないなら、少しくらいは評価してみようと思ってみても良いと思う。

.NET は Microsoft 世界のいわゆる一つの完成形なのだが、Unix/Mac ラバーズがその価値に気付くのは難しいかもしれない。よほど自発的に「評価してみよう」と思わない限り。

.NET 発生の「モチベーション」となった「問題」は、概ね2つだ。

一つ目の「問題」は伝統的な Unix CUI の一般的なプログラム間連携に見出すことが出来る。たとえばこんなだ:

1 me@host: ~$ ls -l | sed '1d' | awk '{ print $1, $6, $7, $8, $9}' | head
2 -rw-r--r-- Oct 25 10:15 aaa.txt
3 drwxr-xr-x Sep 16 17:16 somedir
4 -rw-r--r-- Sep 24 01:45 zzz.txt

コマンド lsawk はお互いのことを知らない。だから awk が「ls の出力のうちファイル名はどやつだ」を知ることはないし、もっといえば ls が一行目に「Total」を出力するということも知りようがない。awk はシンプルにテキストを受け取って「行ごとに指定の区切り文字で分解する」だけであり、分解されたフィールドを意味解釈するのは awk ユーザの責務である。

この問題を言葉にすれば、「プログラム間連携のための標準語が存在しない」(もしくは貧相)、ということである。

二つ目の「問題」はもっとエンジニア寄りだ。すなわち、「オレさまアプリケーションや我が家のライブラリ」を開発する言語やターゲットの「位置」の問題である。以下 C 言語で書いたライブラリ:

Windows 向け __declspec は省略
1 int MYLIBRARY_calc_my_hoge()
2 {
3     return 0;
4 }

これを pure な java から使うことは出来ないし、「普通の手段で」C# から呼び出すことも出来ないし、VBScript から呼び出すことも出来ない。それらをするためには、各言語に用意された C API で「包んで」使う必要があるが、この「包む」が、「包んだライブラリを別途開発する」ことになるものも多く、例えば CPython の CPython C API がそうである。そしてこの C コードが「駅前のスタバ」にあるなら、自宅から呼び出すことは絶望的だ。

あーそうか、それで .NET が作られたのね? まぁ待て。そこに至るまでには長い道のりがあった。実はこの2つの大問題を解決するためのインフラでおそらく最古に属するものは、これは Microsoft とは関係ない CORBA だろうと思う。プログラム間連携のための共通語と位置透過性・バイナリ互換性を解消しようとするものだ。Microsoft が CORBA に触発されて開発した最初期のものが ActiveX だ。これは後に COM と名を変え、ネットワーク透過を加えた DCOM が登場した。.NET は DCOM の直接の子供であり、CORBA の曾孫と言える。

そういうわけで、「CORBA の価値を認めたことがある Unix 古株の Microsoft 嫌い」がいたら、「あーそういうことか」と思ってくれると面白いと思う。

「CORBA の価値を認めたことがある Unix 古株の Microsoft 嫌い」でない人たちへの説明としては…、そうね、「ActiveX のおかげで Microsoft Excel が公開する機能をプログラム(VBScript など)から使えるようになり、その流れからの集大成が .NET なのだだだ」てくらいかな。ただこういう説明だとさ、あんまし「どうオイシイのかワガラン」くない? まぁ一言では「どんな言語からでも位置が離れてても同じように使える」てことね。

よく .NET は「仮想マシン」であることから Java と比較されるが、実はベースになっているのは CORBA 的思想の方であって、いわば「CORBA + java的仮想マシン」が .NET だったりする。ね? なかなかっしょ?

Python から .NET 機能にアクセスする手段について色々

今回紹介したい Python for .NET 以外だと、安直なものからヘビィ過ぎるものまで色々ある。

PowerShell を呼び出す

最も安直(よく言えば素直)だがあまり採用したくない手段は、.NET シェルであるところの PowerShell を呼び出すことだ。それ自身が「シェル」なので、.NET そのものを使うこととは少し離れてしまうため、「遅い・重い」という問題を差し置いたとしても、「嬉しくない」と思うのが普通だ。

Python 用 lex/yacc の話 で例としてやっているのは Visual Studio プロジェクトファイルの解読(の一部)だが、Python for .NET 導入を決断する前は、実際これをやっていた。乗り換えとともに捨てたので未完成度甚だしいが、ただ捨てるのももったいない気もしたので、ここに貼り付けておく:

  1 # -*- coding: utf-8 -*-
  2 #
  3 from __future__ import unicode_literals
  4 from __future__ import absolute_import
  5 from __future__ import print_function
  6 
  7 import os
  8 import sys
  9 import re
 10 import subprocess
 11 import copy
 12 
 13 import six
 14 
 15 
 16 # ---------------------------------------
 17 #
 18 # Internal helpers
 19 #
 20 if sys.version[0] == '2':
 21     str = unicode
 22 
 23 
 24 def _encode(s):
 25     if sys.version[0] == '2':
 26         if hasattr(s, "encode"):
 27             # python 2.x, and this string is 'unicode',
 28             # if so, we have to encode for passing it
 29             # to subprocess or spawn.
 30             return s.encode(sys.getfilesystemencoding())
 31     else:
 32         if hasattr(s, "decode"):
 33             # python 3.x, and this string is 'bytes',
 34             # if so, we have to decode for passing it
 35             # to subprocess or spawn.
 36             return s.decode(sys.getfilesystemencoding())
 37     return s
 38 
 39 
 40 def _fix_env(env):
 41     return {_encode(k): _encode(v) for (k, v) in env.items()}
 42 
 43 
 44 def _lexer(s, tokdefs):
 45     states = ["root"]
 46     state = states[0]
 47     while s:
 48         for rgx, tok, trans in tokdefs[state]:
 49             m = rgx.match(s)
 50             if m:
 51                 yield tok, m.group(0)
 52                 if trans:
 53                     if trans == "#pop":
 54                         states.pop(-1)
 55                         state = states[-1]
 56                     else:
 57                         states.append(trans)
 58                         state = trans
 59                 s = s[m.span()[1]:]
 60                 break
 61 
 62 
 63 _VARS_RGX = re.compile(
 64     r"""
 65     (
 66       (?P<rtype>[$%])
 67         \(
 68           (?P<key>[\w.]+)
 69           (?P<method>\.[\w]+\(
 70               ((?P<args>[\w'`,\s]+))?
 71           \))*
 72         \)
 73     )
 74     """, flags=re.X)
 75 def _accum_all_refs(v):
 76     r"""
 77     >>> print(
 78     ...     "\n".join(
 79     ...         _accum_all_refs(
 80     ...             "$(X)"
 81     ...     )))
 82     X
 83     >>> print(
 84     ...     "\n".join(
 85     ...         _accum_all_refs(
 86     ...             "$(X.Y)"
 87     ...     )))
 88     X.Y
 89     >>> print(
 90     ...     "\n".join(
 91     ...         _accum_all_refs(
 92     ...             "$(X.Y.Length)"
 93     ...     )))
 94     X.Y
 95     >>> print(
 96     ...     "\n".join(
 97     ...         _accum_all_refs(
 98     ...             "$(X.Y.SubString(1, 2))"
 99     ...     )))
100     X.Y
101     >>> print(
102     ...     "\n".join(
103     ...         _accum_all_refs(
104     ...             "$(X.Y.SubString(1, 2))== $(ZZZ)"
105     ...     )))
106     X.Y
107     ZZZ
108     >>> print(
109     ...    "\n".join(
110     ...         _accum_all_refs(
111     ...             "[System.Text.RegularExpressions.Regex]::"
112     ...             "Match($(Xx.Yy.SubString(3, 4)), `x`).Groups[0].Value")))
113     Xx.Yy
114     """
115     result = []
116     _STRING_PROPERTIES = [
117         "Chars",
118         "Length",
119         ]
120 
121     m = _VARS_RGX.search(v)
122     s = v
123     while m:
124         span = m.span()
125         ref_key = m.group("key")
126         rk_cand, _, sprop = ref_key.rpartition(".")
127         if sprop in _STRING_PROPERTIES:
128             ref_key = rk_cand
129         result.append(ref_key)
130         s = s[span[1]:]
131         m = _VARS_RGX.search(s)
132     return result
133 
134 
135 # ---------------------------------------
136 #
137 # Private module variables
138 #
139 _TOKENS = {
140     "translate_from_msbuild_to_powershell": {
141         "root": [
142             (r"\s+", "WSP", ""),
143             (r"\$\((?!\[)", "START", "ref"),
144             (r"`", "QUOTE", "quote"),
145             (r"(==|!=|<=|>=|<|>|and|or)", "BOP", ""),
146             (r".", "REST", ""),
147             ],
148         "ref": [
149             (r"\(", "LPAR", "par"),
150             (r"\)", "END", "#pop"),
151             (r"`", "QUOTE", "quote"),
152             (r".", "REST", ""),
153             ],
154         "par": [
155             (r"\(", "LPAR", "par"),
156             (r"\)", "RPAR", "#pop"),
157             (r"`", "QUOTE", "quote"),
158             (r".", "REST", ""),
159             ],
160         "quote": [
161             (r"`", "QUOTE", "#pop"),
162             (r".", "REST", ""),
163             ],
164         },
165     }
166 for k in _TOKENS.keys():
167     _toks = _TOKENS[k]
168     for kk in _toks.keys():
169         for i, (rgxstr, tok, trans) in enumerate(_toks[kk]):
170             rgx = re.compile(rgxstr, flags=re.I)
171             _toks[kk][i] = (rgx, tok, trans)
172 
173 
174 # ---------------------------------------
175 #
176 # Exposed Helpers
177 #
178 def pydata_to_powershell_data(v):
179     r"""
180     >>> print(pydata_to_powershell_data(1))
181     1
182     >>> print(pydata_to_powershell_data(2**64))
183     18446744073709551616
184     >>> print(pydata_to_powershell_data(''))
185     ''
186     >>> print(pydata_to_powershell_data('""'))
187     '""'
188     >>> print(pydata_to_powershell_data('"aaa"'))
189     '"aaa"'
190     >>> print(pydata_to_powershell_data("'aaa'"))
191     "'aaa'"
192     >>> print(pydata_to_powershell_data([]))
193     {}
194     >>> print(pydata_to_powershell_data(["a", "b"]))
195     {'a','b'}
196     >>> print(pydata_to_powershell_data({"a": "b"}))
197     @{'a'='b'}
198     >>> print(pydata_to_powershell_data({"a": ["b", "c"]}))
199     @{'a'={'b','c'}}
200     >>> print(pydata_to_powershell_data({"a": {"b": "x", "c": "y"}}))
201     @{'a'=@{'b'='x','c'='y'}}
202     >>> print(pydata_to_powershell_data({"a": {"b": "x", "c": 3}}))
203     @{'a'=@{'b'='x','c'=3}}
204     """
205     # list -> {...,...}
206     # hashtable -> @{"..."="...",}
207     if isinstance(v, six.string_types):
208         return re.sub(r"""^[urb](["'])""", r"\1", repr(v))
209     elif isinstance(v, (tuple, list,)):
210         return "{{{}}}".format(
211             ",".join([
212                     pydata_to_powershell_data(vi)
213                     for vi in v
214                     ]))
215     elif isinstance(v, (dict,)):
216         return "@{{{}}}".format(
217             ",".join([
218                     "{}={}".format(
219                         pydata_to_powershell_data(ik),
220                         pydata_to_powershell_data(iv))
221                     for ik, iv in sorted(v.items())
222                     ]))
223     else:
224         return str(v)
225 
226 
227 def to_powershell_assign(name, value):
228     r"""
229     >>> print(to_powershell_assign("X", 1))
230     $X = 1
231     >>> print(to_powershell_assign("X", 2**64))
232     $X = 18446744073709551616
233     >>> print(to_powershell_assign("X", ''))
234     $X = ''
235     >>> print(to_powershell_assign("X", '""'))
236     $X = '""'
237     >>> print(to_powershell_assign("X", '"aaa"'))
238     $X = '"aaa"'
239     >>> print(to_powershell_assign("X", "'aaa'"))
240     $X = "'aaa'"
241     >>> print(to_powershell_assign("X", []))
242     $X = {}
243     >>> print(to_powershell_assign("X", ["a", "b"]))
244     $X = {'a','b'}
245     >>> print(to_powershell_assign("X", {"a": "b"}))
246     $X = @{'a'='b'}
247     >>> print(to_powershell_assign("X", {"a": ["b", "c"]}))
248     $X = @{'a'={'b','c'}}
249     """
250     return "${} = {}".format(
251         name,
252         pydata_to_powershell_data(value))
253 
254 
255 def translate_from_msbuild_to_powershell(command):
256     r"""
257     >>> print(translate_from_msbuild_to_powershell(
258     ...    "[System.Text.RegularExpressions.Regex]::"
259     ...    "Match(`x`, `x`).Groups[0].Value"))
260     [System.Text.RegularExpressions.Regex]::Match('x', 'x').Groups[0].Value
261     >>> print(translate_from_msbuild_to_powershell(
262     ...    '$(Get-Item Env:XXX).Value'))
263     $(Get-Item Env:XXX).Value
264     >>> s = '''\
265     ... $([System.Text.RegularExpressions.Regex]::\
266     ... Match($(V), `(\d+)`).Groups[0].Value)'''
267     >>> print(translate_from_msbuild_to_powershell(s))
268     $([System.Text.RegularExpressions.Regex]::Match($V, '(\d+)').Groups[0].Value)
269     >>> s = '''\
270     ... $([System.Text.RegularExpressions.Regex]::\
271     ... Match($(V.X), `(\d+)`).Groups[0].Value)'''
272     >>> print(translate_from_msbuild_to_powershell(s))
273     $([System.Text.RegularExpressions.Regex]::Match($V.X, '(\d+)').Groups[0].Value)
274     >>> s = '''\
275     ... $([System.Text.RegularExpressions.Regex]::\
276     ... Match($(V.X.Replace(`1`, `3`)), `(\d+)`).Groups[0].Value)'''
277     >>> print(translate_from_msbuild_to_powershell(s))
278     $([System.Text.RegularExpressions.Regex]::Match($V.X.Replace('1', '3'), '(\d+)').Groups[0].Value)
279     >>> print(translate_from_msbuild_to_powershell("$(A) == ''"))
280     $A -eq ''
281     >>> print(translate_from_msbuild_to_powershell("$(A)==''"))
282     $A -eq ''
283     >>> print(translate_from_msbuild_to_powershell("$(A) != ''"))
284     $A -ne ''
285     >>> print(translate_from_msbuild_to_powershell("$(A) <= ''"))
286     $A -le ''
287     >>> print(translate_from_msbuild_to_powershell("$(A) >= ''"))
288     $A -ge ''
289     >>> print(translate_from_msbuild_to_powershell("$(A) < ''"))
290     $A -lt ''
291     >>> print(translate_from_msbuild_to_powershell("$(A) > ''"))
292     $A -gt ''
293     """
294     cnv = []
295     ref = []
296     for tok, s in _lexer(
297         command,
298         _TOKENS["translate_from_msbuild_to_powershell"]):
299         #
300         if tok == "START":
301             ref.append(s)
302         elif tok in ("LPAR", "RPAR"):
303             ref.append(s)
304         elif tok == "WSP":
305             cnv.append(" ")
306         elif tok == "BOP":
307             bop = {
308                 "==": "###SP###-eq###SP###",
309                 "!=": "###SP###-ne###SP###",
310                 "<=": "###SP###-le###SP###",
311                 ">=": "###SP###-ge###SP###",
312                 "<": "###SP###-lt###SP###",
313                 ">": "###SP###-gt###SP###",
314                 "and": "###SP###-and###SP###",
315                 "or": "###SP###-or###SP###",
316                 }[s.lower()]
317             cnv.append(bop)
318         elif tok == "QUOTE":
319             if ref:
320                 ref.append("'")
321             else:
322                 cnv.append("'")
323         elif tok == "END":
324             ref.append(s)
325             tmp = "".join(ref)
326             if re.match(
327                 r"^[A-Z0-9_][A-Z0-9_-]+\s",
328                 tmp[len("$("):],
329                 flags=re.I):
330                 # if ref is like "$(Get-Item Env:Xxx)", don't change it.
331                 cnv.append(tmp)
332             else:
333                 # otherwise, change "$(Aaa)" to "$Aaa".
334                 ref[0] = "$"
335                 cnv.append("".join(ref[:-1]))
336             ref = []
337         elif tok == "REST":
338             if ref:
339                 ref.append(s)
340             else:
341                 cnv.append(s)
342     return re.sub(r"\s*###SP###\s*", " ", "".join(cnv))
343 
344 
345 # ---------------------------------------
346 #
347 # 
348 #
349 def exec_single_command(
350     command,
351     props={},
352     env={},
353     merge_osenv_to_env=True,
354     from_msbuild=False):
355     r"""
356     Execute single powershell command. "command" must be
357     exactly one statement. For example, you can't pass
358     like "$A = 1 ; $A".
359 
360     >>> print(exec_single_command(
361     ...    "[System.Text.RegularExpressions.Regex]::"
362     ...    "Match('x', 'x').Groups[0].Value"))
363     x
364     >>> print(exec_single_command(
365     ...    "[System.Text.RegularExpressions.Regex]::"
366     ...    "Match(`x`, `x`).Groups[0].Value", from_msbuild=True))
367     x
368     >>> print(exec_single_command(
369     ...    "[System.Text.RegularExpressions.Regex]::"
370     ...    "Match('x', $A).Groups[0].Value", props={"A": "x"}))
371     x
372     >>> print(exec_single_command(
373     ...    "[System.Text.RegularExpressions.Regex]::"
374     ...    "Match(`x`, $(A)).Groups[0].Value", props={"A": "x"},
375     ...    from_msbuild=True))
376     x
377     >>> print(exec_single_command(
378     ...    "$A.Length", props={"A": "xyz"}))
379     3
380     >>> s = "$A.Contains('xyz')"
381     >>> print(exec_single_command(
382     ...    s, props={"A": "xyz"}))
383     True
384     >>> print(exec_single_command(
385     ...    '$A.B', props={"A": {"B": "xyz"}}))
386     xyz
387     >>> print(exec_single_command(  # N/F child key
388     ...    '$A.B', props={"A": {}}))
389     <BLANKLINE>
390     >>> print(exec_single_command(  # N/F parent key
391     ...    '$A.B', props={}))
392     <BLANKLINE>
393     >>> print(exec_single_command(
394     ...    '$A.B.Contains("xyz")', props={"A": {"B": "xyz"}}))
395     True
396     >>> print(exec_single_command(
397     ...    '$B.Contains("xyz")', props={"A": {"B": "xyz"}}))
398     True
399     >>> print(exec_single_command(
400     ...    '$(A.B.Contains(`xyz`))', props={"A": {"B": "xyz"}},
401     ...    from_msbuild=True))
402     True
403     >>> fn = os.path.join(
404     ...     os.path.dirname(sys.modules[__name__].__file__),
405     ...     '__init__.py')
406     >>> print(exec_single_command(
407     ...    "$([System.IO.File]::"
408     ...    "ReadAllText('" + fn + "').SubString(0, 3))"))
409     # -
410     >>> env = {}
411     >>> env.update(os.environ)
412     >>> env["XXX"] = "YYY"
413     >>> print(exec_single_command(
414     ...    '$(Get-Item Env:XXX).Value',
415     ...    props={}, env=env))
416     YYY
417     >>> print(exec_single_command(
418     ...    '$(Get-Item Env:XXX).Value',
419     ...    props={}, env={"XXX": "YYY"}))
420     YYY
421     >>> print(exec_single_command(
422     ...    "$(A) == 'xxx'", props={"A": "xxx"},
423     ...    from_msbuild=True))
424     True
425     >>> print(exec_single_command(
426     ...    "$(A) == '3'", props={"A": "3"},
427     ...    from_msbuild=True))
428     True
429     >>> print(exec_single_command(
430     ...    "$(A) == 3", props={"A": 3},
431     ...    from_msbuild=True))
432     True
433     >>> print(exec_single_command(
434     ...    "!!($(A) == 3)", props={"A": 3},
435     ...    from_msbuild=True))
436     True
437     >>> exec_single_command(
438     ...    "$(A) == 2 OR $(A) == 3", props={"A": 3},
439     ...    from_msbuild=True)
440     True
441     >>> exec_single_command(
442     ...    "$(A.Length) == 2 AND $(A) == '33'", props={"A": "33"},
443     ...    from_msbuild=True)
444     True
445     >>> exec_single_command(
446     ...    "'True'", props={"A": "33"},
447     ...    from_msbuild=True) == 'True'
448     True
449     >>> exec_single_command(
450     ...    "'True'", props={"A": "33"},
451     ...    from_msbuild=True) is not True
452     True
453     >>> print(exec_single_command(  # N/F parent key
454     ...    '$A.B -eq $null', props={}, from_msbuild=False))
455     True
456     >>> print(exec_single_command(  # N/F parent key
457     ...    '$(A.B) == ""', props={}, from_msbuild=True))
458     True
459     >>> exec_single_command(
460     ...    '"ABC".CompareTo("ABCD")', props={})
461     -1
462     """
463     # TODO: [MSBuild]
464     props_cp = copy.deepcopy(props)
465     if from_msbuild:
466         # powershell-like expression in msbuild allows
467         # the comparison beteween null value and blank
468         # sting like "$(A) == ''" even if variable A
469         # is not defined, but real powershell doesn't
470         # allow that. In powershell, if we want to
471         # do like that, we must use "$null" or
472         # "[string]::IsNullOrEmpty(...)", but in msbuild
473         # also allows like "$(A.SubString(0, 2)) == ''"
474         # even if variable A is not defined, in this
475         # case it is too hard to convert the expression.
476         # So, let's try to "default" properties that is
477         # referenced by the expression.
478         refs = _accum_all_refs(command)
479         for rk in refs:
480             k = rk.split(".")
481             td = props_cp
482             for i, ki in enumerate(k):
483                 if ki not in td:
484                     if i == len(k) - 1:
485                         td[ki] = ""
486                     else:
487                         td[ki] = {}
488                     td = td[ki]
489         command = translate_from_msbuild_to_powershell(
490             command)
491     command = "\r\n".join([
492             "$__RESULT = ({})".format(command),
493             "Try { $__RESULT.GetType().FullName }\r\n"
494             "Catch { '\r\n' }",
495             "$__RESULT"])
496     if merge_osenv_to_env:
497         env.update(os.environ)
498     if props_cp:
499         assigns = []
500         for k in props_cp.keys():
501             if isinstance(props_cp[k], (dict,)):
502                 for ki in props_cp[k].keys():
503                     assigns.append(
504                         to_powershell_assign(ki, props_cp[k][ki]))
505             assigns.append(
506                 to_powershell_assign(k, props_cp[k]))
507         if assigns:
508             command = "\r\n".join(["\r\n".join(assigns), command])
509     command = _encode(command)
510     try:
511         output = subprocess.check_output(
512             [
513                 "powershell",
514                 "-Command",
515                 _encode(command),
516                 ],
517             shell=False,
518             env=_fix_env(env),
519             stderr=subprocess.STDOUT).decode("mbcs").replace("\r\n", "\n")
520         if output[-1] == "\n":
521             # omit only *LAST* newline
522             output = output[:-1]
523     except subprocess.CalledProcessError as e:
524         # TODO: what should we do?
525         raise
526     res = output.split("\n")
527     #print(command.replace("\r\n", "\n"), res, "\n\n", file=sys.stderr)
528     assert len(res) == 2, res
529     if res[0] == "System.Boolean":
530         return res[1].lower() == "true"
531     if re.match(r"^System\.U?Int\d+$", res[0]):
532         return int(res[1])
533     return res[1]
534 
535 
536 if __name__ == '__main__':
537     import doctest
538     doctest.testmod()

.NET そのものでないだけでなく、PowerShell 構文と MSBuild 式が大きく異なっているために、その変換をごちゃごちゃやっている。あーやだやだ。

VBScript とか JScript を呼び出す

出来ると仮定すれば、PowerShell を呼び出すのとそう大差はないものの、おそらく PowerShell がマシである。PowerShell の方が .NET に「近い」。JScript などでは、.NET オブジェクトを構築するだけでもゴテゴテ書く必要がある、だろう。

「仮定」? ゴメンよ、やったこたぁねいのだ。VBScript、JScript は「COM テクノロジ」の方に寄り添って作られたもの。多分 VBScript.NET とか JScript.NET なんて名前を見かけるので、出来るんであろうけれど、これが「今の Windows では JScript == JScript.NET、だったらありがたい」とお祈りしてるくらいだから、よーするになんも知らん。のになぜ「PowerShell がマシに違いない」と思うのかといえば、JScript、VBScript 言語にとって「COM 連携は言語の拡張」だったから。要は「余分な手続きを踏まないと COM を使えない」だったから。

IronPython を使う

ちょうど java と python の jython に対応する、「Microsoft 製の、.NET で構築された Python」が IronPython であり、Iron シリーズの(記憶が正しければ)第一作目である。案外 IronRuby のほうが有名だったりしそうだけれども。

一度も使ったことがないのでわからんが、多分 .NET にフルアクセス出来るはず。それも「pythonic に」。

.NET ラッパーを CPython C API と clr で自作する

考えたくもない、が、CPython C API と、Visual C++ の clr 連携拡張を使えば、相当頑張れば書ける、と思う。ただし…。

distutils など標準 python 範疇での C のビルドは clr に関して無知なので、コーディングよりも先に、「どうやってビルドすんねん」だけで下手すれば何日も費やすこととなるだろう。

というかまさにそれをしてくれたのが Python for .NET だ。

そして Python for .NET

動機たるもの

IronPython などのオルタナティブ Python であってはならない、公式 CPython でないと困る、からだ、少なくともアタシにとっては。

公式 CPython でないと困る一番の理由は大抵は C 拡張に依存したライブラリだ。間接的にはつまり Cython で書かれたライブラリなんかもこれにあたる。

IronPython が受け容れられるなら Python for .NET はなくてもいいんじゃないかなと思う、多分。

ハロー過ぎるワールド

Python for .NET 公式サイトの最初の説明は歴史的な事情からか、ちょっと不親切な順番で始まる:

1 >>> from System import String
2 Traceback (most recent call last):
3   File "<stdin>", line 1, in <module>
4 ImportError: No module named System
5 >>> #

古いバージョンでは自動ロードに頼っていたためにこれでインポート出来ていたらしい。

ワタシなんぞは最初からそうせい、と思ったりするが、他の言語から .NET 機能を使う場合と全く同じノリで、「アッセンブリ参照を解決する」という手続きが予め必要である:

1 >>> import clr
2 >>> clr.AddReference("System")
3 <System.Reflection.RuntimeAssembly object at 0x0000000002BA2278>
4 >>> import System
5 >>> s = System.String("abc")
6 >>> s.Substring(1)
7 'bc'

.NET モジュール・関数・プロパティ等々、すべて「普通の Python にみえる」。だから特殊なのは(多言語からの使用と同様)「アッセンブリ参照を追加する」部分だけだ。

アッセンブリは部分名で参照しようとすると衝突が問題になることがあるけど、完全名でもイケるんかな? ちょっとわからない。必要になりそうになったら調べよう…。

若干ハローじゃないワールド

「高等」てほどでもないが、ワタシが MSBuild 式を評価するのに使いたかったがために、つまり「文字列からこれらをコンストラクトしたり呼び出したりせねばならん」かったのね。別に Python for .NET に限った話ではないけれど、上述のアッセンブリの関係があるために、若干ややこしいのであった:

class 名がプライベート向けなのは無論オレライブラリでプライベートだからさ
 1 class _DotNetLoader(object):
 2     def __init__(self):
 3         self._clr = None
 4         self._loaded_assemblies = {}  # value: success or not
 5 
 6         # value: imported assembly (as python module)
 7         self._imported_assemblies = {}
 8 
 9     def _import_clr(self):
10         if self._clr is None:
11             import clr  # Python for .NET (pythonnet)
12             self._clr = clr
13 
14     def load_assembly(self, asmn):
15         if asmn not in self._loaded_assemblies:
16             self._import_clr()
17             try:
18                 self._clr.AddReference(asmn)
19                 self._loaded_assemblies[asmn] = True
20             except Exception as e:
21                 # actually it should be "System.IO.FileNotFoundException",
22                 # but we can't assume it when no assemblies are loaded.
23                 if "'System.IO.FileNotFoundException'" not in str(type(e)):
24                     raise
25                 # this nsref is not assembly (maybe module fullname)
26                 self._loaded_assemblies[asmn] = False
27         return self._loaded_assemblies[asmn]
28 
29     def import_assembly(self, asmn):
30         if asmn not in self._imported_assemblies:
31             self._import_clr()
32             try:
33                 exec("import " + asmn)
34             except ImportError:
35                 raise  # what should we do?
36             self._imported_assemblies[asmn] = eval(asmn)
37         return self._imported_assemblies[asmn]
38 
39     def get_function(self, name_with_ns):
40         # name_with_ns: like "[System.IO.Path]::Add"
41         self._import_clr()
42         ns, name = name_with_ns.split("]::")
43         ns_spl = ns[1:].split(".")  # like "System", "IO", "Path"
44         asmn, modn = None, None
45         for i in range(len(ns_spl), 1, -1):
46             asmn = ".".join(ns_spl[:i - 1])
47             modn = ".".join(ns_spl[i - 1:])
48             if self.load_assembly(asmn):
49                 break
50         asm = self.import_assembly(asmn)
51         return getattr(getattr(asm, modn), name)
52 
53 
54 _dotnetloader = _DotNetLoader()
55 _dotnetloader.load_assembly("System")
56 _dotnet_System = _dotnetloader.import_assembly("System")

get_function つぅ具合に、関数・メソッドをダイレクトに取り出す公開部分しかないのは、こんな評価式にしか用事がないから:

1 "$([System.IO.Path]::Combine(`a`, `b`))"

これの場合、System.IOAddReference に渡すもの、Path がモジュール、だったかな、確か。

これをこのまんま使いたい人がいるともあんまし思えないけれど、まぁ使いたかったら使いなはれ。完全に正しいかは知らんよ。MSBuild で書ける限られたものでしか試してないもん。(それだけでなく「MSBuild 式の評価ライブラリ」自体が絶賛作り中であって、全然全貌網羅しとらん。)

↑からの派生ひとつ

「派生」がオブジェクト指向専門用語だと思いこんでいそうな人がいそうなんでコワイが、一般的日本語の意味での「派生」ね。

やはり同じく「MSBuild 式の評価ライブラリ」からのご用事。ちょっとした事情があって、「.NET の System.String」と Python 本体の文字列を同一視したかったのである。ついでに MSBuild に許される「ignore case」な凶悪な仕様にも耐える必要があった。ので、「isinstance(obj, (str,)) が真を返してもらわないと困る」兼「メソッド名の大文字小文字を問わない」の2つを施したヤツ:

_dotnetloader は上のヤツね
 1 # -*- coding: utf-8 -*-
 2 #
 3 from __future__ import absolute_import
 4 from __future__ import unicode_literals
 5 from __future__ import print_function
 6 
 7 import sys
 8 
 9 if sys.version[0] == '2':
10     str = unicode
11 
12 # ...
13 
14 class String(str):
15     r"""
16     >>> s = String("abc")
17     >>> isinstance(s, (str,))
18     True
19     >>> isinstance(s, six.string_types)
20     True
21     >>>
22     >>> tab = [
23     ...     # (lhs, rhs, expected)
24     ...     #     same length
25     ...     ("A", "A", 0),
26     ...     ("A", "a", 1),
27     ...     ("a", "A", -1),
28     ...     ("AAA", "AAA", 0),
29     ...     ("AAA", "aAA", 1),
30     ...     ("aAA", "AAA", -1),
31     ...     #
32     ...     #     len(self) > len(rhs)
33     ...     ("AAAa", "AAA", 1),
34     ...     ("AAAa", "aAA", 1),
35     ...     ("aAAa", "AAA", 1),
36     ...     ("aAAaa", "AAA", 1),
37     ...     #
38     ...     #     len(self) < len(rhs)
39     ...     ("AAA", "AAAA", -1),
40     ...     ("AAA", "aAAA", -1),
41     ...     ("aAA", "AAAA", -1),
42     ...     ("aAA", "AAAAA", -1),
43     ... ]
44     >>> for lhs, rhs, expected in tab:
45     ...     result = String(lhs).CompareTo(rhs)
46     ...     assert result == expected, str((lhs, rhs, expected, result))
47     ...     #from ._powershell import exec_single_command
48     ...     #expected2 = exec_single_command('"{}".CompareTo("{}")'.format(lhs, rhs))
49     ...     #assert expected == expected2, str((lhs, rhs, expected2, expected))
50     ...     #assert result == expected2, str((lhs, rhs, expected2, result))
51     >>> 
52     >>> String("AAA").Contains("AAA")
53     True
54     >>> String("   AAA   ").Contains("AAA")
55     True
56     >>> String("BBB").Contains("AAA")
57     False
58     >>> String("AAA").Contains("   AAA   ")
59     False
60     >>>
61     >>> s = String("AAA")
62     >>> print(s.Replace("AAA", "BBB"))
63     BBB
64     >>>
65     >>> print(String("abc").Substring(0))
66     abc
67     >>> print(String("abc").Substring(0, 1))
68     a
69     >>> print(String("abc").Substring(1))
70     bc
71     >>> print(String("abc").Substring(1, 1))
72     b
73     >>> print(String("abc").Substring(1, 2))
74     bc
75     >>> print(String("abc").SubString(1, 2))
76     bc
77     >>>
78     >>> s = String("abc")
79     >>> s.Length
80     3
81     """
82     def __new__(cls, value):
83         # i believe that all methods in System.String are immurable.
84         # if it's dought, my emulator class is broken...
85         if isinstance(value, String):
86             obj = value
87         else:
88             obj = str.__new__(cls, value)
89             dnscls = getattr(_dotnet_System, "String")
90             obj._dotnetString = dnscls(value)
91         return obj
92 
93     def __getattr__(self, name):
94         # Unfortunatelly, Microsoft always ignores its case...
95         for attr in [
96             x for x in dir(self._dotnetString)]:
97 
98             if attr.lower() == name.lower():
99                 return getattr(self._dotnetString, attr)

結果としてえれーシンプルに書けてるけど、実は密かにハマったのは内緒である。setattr でメソッドを移送しようとしてた。これだと一見動くがなんかハングアップ的なことが起こった。何がダメだったのかわからずじまいだったが、この正解に気付いたのでそれ以上追ってない。

当然「isinstance(obj, (str,)) が真を返してもらわないと困る」はそんなにメジャーな要件ではないと思うが、ワタシの場合は「既存の 「isinstance(obj, (str,)) に頼った処理」内にしれーっとこの .NET String を混ぜ込む必要があったからに過ぎない。そうでないなら普通にダイレクトに Python for .NET の String を使えば良かろう。

String 以外でもこれやりたいのよねー。ただなぁ…、.NET もなんだかんだ C/C++ の子孫なので、「Uint32」だのが Python とか Ruby みたいな「原則型なし(にみえる)」言語からは扱いずれーんだよね。

その他

ないよ、別に。もう「Python にしか見えないし .NET にしか見えない」んだもん、非常に素直にストレートに使える。「アッセンブリ」の件を除けば。

オススメ、と思うぞ。

2017-11-19追記: 「Python for .NET」という名前について

真っ先に書こうと思っていたのに完全に失念してた。

ワタシのご用がもとより「Python から .NET にアクセスしたい」だったわけなんだけれども、だとするとこの名前からは「違うよな多分」と思うはずでしょ? 欲しいのは「.NET for Python」であろう。

この疑問は無論すぐに浮かんだわけなんだけれど、公式ドキュメントを斜め読みして「なんだよ .NET for Python じゃん」と理解してこの記事になった。

そうなんだけど、やっぱこの命名、ヘンだよな、なんか書かれてないかしら、と思ったらあった:

名前から察するに、もともとこっちが目的だったのかもしれんね。だとしてもドキュメントが大々的に説明するのは「.NET for Python」側にはやっぱしみえるね。

ワタシは PowerShell をたまに使うくらいで基本的に .NET べったりの生活してないんで、当面は自分で試そうとは思わないけれど、まさに PowerShell から埋め込み Python てのも面白い可能性はあるね。簡単に出来るかはわからんけど。