Python で書かれたプログラムは、インタプリタさえ入っている環境であれば起動できます。また、多くのコードやライブラリは Windows でも Linux でも正常に動作します。そのため、次のような効果が期待できます。
- Python で作成した便利なツールやライブラリが Windows ユーザにも Linux ユーザにも貢献できる
- Python で作成した製品を、Windows を好む企業にも Linux を好む企業にも販売できる
- Windows でも Linux でも開発できる
ところが、現実にはあらゆる Python プログラムが Windows でも Linux でも動作するわけではありません。開発するアプリケーションの仕様やコーディングの仕方によっては、一方でしか動作しないものになることもあります。この記事では、そのような事態を避けるためにコーディング時 (一部は設計時・仕様策定時) に気を付けるべき項目を列挙します。
ファイルパス編
Windows と Linux の大きな違いの一つとしてファイルシステムが挙げられます。この違いを適切に吸収できなければ、Windows と Linux の双方で動作するプログラムにはなりません。
とはいえ、標準ライブラリが豊富な Python ではこの差を簡単に吸収する機能が提供されているので、簡単にコーディングできます。(後述しますが、設計レベルでは若干の検討が必要です。)
os.path か pathlib を使い、文字列の連結 +
は使わない
(注) 実は Windows Linux 相互対応にはあまり寄与しないのですが、この後たくさん使うので先にふれておきます。
とりあえずこれ使っとけというモジュールが、os.path
と pathlib
です。これらはいずれもファイルパスやファイルそのものへの操作を提供します。もちろんパスの連結もです。
なぜ文字列連結ではだめなのか
パスの連結は文字列の連結でもできそうですが、下記のような罠が潜んでいます。
import os
# ディレクトリパスを環境変数から取得。環境変数が設定されていなければデフォルト値を使用する。
# (BASE_DIR には /home/user1/basedir みたいにスラッシュが末尾にないパスが入ってるかもよ……)
directory_path = os.environ.get('BASE_DIR', '/home/user1/basedir/')
# (もし環境変数に「/home/user1/basedir」が入っていたら
# /home/user1 ディレクトリにある basedirexample.txt を指すけど合ってるの!?)
target_file_path = directory_path + 'example.txt'
上記の例では、環境変数から取得したディレクトリパスにファイル名を連結しています。このコードを知っている立場からすれば環境変数には末尾にスラッシュが付いたパスを入れておいてほしいところですが、このソースを書いた人とディレクトリパスを設定する人は別人なので、上記のような食い違いは想定すべきです 1 2。
修正後のコードは次の通りです。
import os
# ディレクトリパスを環境変数から取得。環境変数が設定されていなければデフォルト値を使用する。
directory_path = os.environ.get('BASE_DIR', '/home/user1/basedir/')
target_file_path = os.path.join(directory_path, 'example.txt')
絶対パスをコードにベタ打ちしない
そもそも絶対パスを固定値にすると単体テストに支障をきたすので避けるべきですが、Linux に慣れすぎると「これは固定値でもOK」とついしてしまうものも、Windows で動かす際の障壁になります。
それを踏まえて、絶対パスをコード中に固定値として書くことは禁止し、次のいずれかにします。
- 環境変数など外部から変更可能なところからパスを受け取る
- ↑で受け取ったパスと固定値である相対パスを連結する
-
os.name
,sys.platform
の値に応じて異なるパスを使用する - 固定値の相対パスをそのまま使う (単体テストをしづらい場合があるため要検討)
- 役割に応じたモジュールや変数を使用する
対策: 環境変数など外部から変更可能なところからパスを受け取る
最も単純なのはパスそのものを外部から受け取るようにすることです。
以下の例では、環境変数や実行時引数からパスを受け取っていて、設定されていない場合は固定の相対パスを使うようにしています。
import argparse
import os
# 環境変数から受け取る例
log_config_path = os.environ.get("LOG_CONFIG_PATH", ".myapp/logging.json")
# 引数から受け取る場合 (argparse について今回の説明に関係ない箇所は省略)
parser = argparse.ArgumentParser()
parser.add_argument("--log-config-path", default=".myapp/logging.json")
args = parser.parse_args()
log_config_path = args.log_config_path
デフォルト値として相対パスではなく絶対パスを使いたい場合、その固定値は後述する「os.name
, sys.platform
の値に応じて異なるパスを使用する」方法で決定することで、Linux でも Windows でも動作するようにできます。
対策: 環境変数など外部から変更可能なところから受け取ったパスと固定値である相対パスを連結する
派生形として、ディレクトリ構造が決まっている場合は、そのベースとなるディレクトリのパスを受け取り、ディレクトリ構造に応じた固定値を用いるという方法もあります。以下の例では、Java のルートディレクトリを受け取って、その下にあるはずのファイル conf/security/java.security
へのパス java_security_path
を作っています。
import argparse
import os
# 環境変数から受け取る例 (設定が無ければエラー)
java_home = os.environ["JAVA_HOME"]
java_security_path = os.path.join(java_home, "conf", "security", "java.security")
# 引数から受け取る場合 (argparse について今回の説明に関係ない箇所は省略)
parser = argparse.ArgumentParser()
parser.add_argument("--java-home", required=True)
args = parser.parse_args()
java_home = args.java_home
java_security_path = os.path.join(java_home, "conf", "security", "java.security")
対策: os.name
, sys.platform
の値に応じて異なるパスを使用する
他の方法が使用できずどうしても固定値で絶対パスを記載する必要があれば、OS に応じて動作を変える方法が使えます。そのためには os.name
もしくは sys.platform
を参照します。
import sys
# OS によって異なる固定値を使用する
if sys.platform == "win32":
app_default_path = "C:\\MyApp\\"
elif sys.platform in ("linux", "darwin"):
app_default_path = "/MyApp/"
else:
raise RuntimeError("想定外の sys.platform")
# 先述の「外部から変更可能なところからパスを受け取る」のデフォルト値として
# 上で選んだ固定値を使う
app_path = os.environ.get("MYAPP_HOME", app_default_path)
多くの場合、先述した「外部から変更可能なところからパスを受け取る」や後述する「役割に応じたモジュールや変数を使用する」で解決できます。まずはそちらを検討し、解決しない問題がある場合にこの方法を検討してください。
対策: 固定値の相対パスをそのまま使う
相対パスであれば OS の違いがない (区切り文字 "/"
と "\"
については "/"
を使っておけば windows でも動作する) ので、「windowsでもlinuxでも動く」という点では意識することがあまりありません。
固定値の相対パスを使う場合、動作はカレントディレクトリに依存します。シェルのカレントディレクトリの考え方にそぐわない使い方をすると、他のコマンドやアプリケーションとの親和性が損なわれてしまいます。
固定値でのパス指定は単体テストの障壁となる場合があるので、多くの場合は「外部から変更可能なところからパスを受け取る」方法を使用し、外部からの指定がない場合のデフォルト値としてのみ固定値を使う方法が無難です。
対策: 役割に応じたモジュールの機能を使用する
モジュール os
をはじめとして、いくつかのモジュールには OS ごとの違いを吸収できる様々な機能が用意されています。ここではとくに特別なファイルパスに関するものをいくつか列挙します。
出力を捨てる "/dev/null"
("nul"
) は os.devnull
にする
Linux のシェルでは /dev/null
へ出力すると標準出力や標準エラー出力を捨てられることが知られています。Windows では nul
が同じ役割を担っています。
Python コード内でもこれらを出力を捨てる方法として使用でき、とくに os.devnull
に OS に応じて /dev/null
もしくは nul
が格納されています。そのため、次のようにすれば OS によらず出力を捨てることができます。
import os
from contextlib import redirect_stdout
import subprocess
# os.devnull をファイルとして開いて出力先として使うと、ファイルにも標準出力にも出力されない。
with open(os.devnull, mode="w") as nul_fp:
print("出力を捨てるテスト", file=nul_fp)
# 標準出力の出力先を変更する redirect_stdout と組み合わせると、
# すべての標準出力を捨てることができる。
with redirect_stdout(open(os.devnull, mode="w")):
print("出力を捨てるテスト")
一時ファイルは tempfile
の機能で作成する
一般に一時ファイルの格納場所と言えば /tmp
ですが、当然 windows では通用しません。tempfile
モジュールを通してファイルを作ることで、ディレクトリパスを意識せずに一時ファイルを作成できます。
import tempfile
# テキストを書き込みたいので mode="w" (デフォルトはバイナリ書き込みストリームになる)
with tempfile.NamedTemporaryFile(mode="w", delete=False) as tmp_fp:
print("test", file=tmp_fp)
print(tmp_fp.name)
# -> 'C:\\Users\\ユーザ名\\AppData\\Local\\Temp\\tmpyu1qzde6'
# delete=False にしたので、コンテキストの外でもまだファイルは残っている
assert os.path.exists(tmp_fp.name)
windows との協調を考えない場合でも tempfile
を使うべきです。/tmp
に作りたい一時ファイルの多くは「既存のファイルとファイルパスが重複しないこと」「他のユーザから見えないこと」が求められますが、 tempfile
モジュールの機能を使えばこれらを満たしてくれます。
ユーザのホームディレクトリは expanduser("~")
で取得する
ホームディレクトリの場所もOSによって異なります。Python ではホームディレクトリを意味する "~"
を os.path.expanduser
で展開することでホームディレクトリのパスを得ることができます。
import os
# ホームディレクトリを取得
user_home = os.path.expanduser("~")
# ホームディレクトリの下にある git のグローバル設定ファイルのパスを取得
git_global_config = os.path.expanduser("~/.gitconfig")
ファイルシステム編
ファイルパス編にもファイルシステム依存の問題を記載しましたが、ここではそれ以外でファイルシステムに起因する問題への対策を記載します。
大文字小文字の区別を除いて同名のファイルを作成しない
これは次の2つの意味を持ちます。
- プログラムの構成ファイルとして、大文字小文字の区別を除いて同名のパスとなるファイルを作成しない
- 例:
app.py
とApp.py
の両方を同じディレクトリに作成しない
- 例:
- プログラムの生成物として、大文字小文字の区別を除いて同名のパスとなるファイルを作成しない
- 例: プログラムの実行の結果として、同じディレクトリに
a.jpg
とA.JPG
を作成しない
- 例: プログラムの実行の結果として、同じディレクトリに
どちらの配慮も、Windows のファイルシステムは大文字小文字を区別しない ことが原因です。そのため、上記のルールを破ると次のような現象が生じてしまいます。
- Linux 上で開発者が
./app.py
と./App.py
を作成し、git に登録した場合- このリポジトリを Windows でクローンすると、
./app.py
のみ存在し./App.py
が存在しない状態になるので、Windows上で開発できない - Windows上でアプリケーションを動作させようとしても、
./app.py
と./App.py
のいずれかが存在しないため正常に動作しない
- このリポジトリを Windows でクローンすると、
- プログラムの実行の結果として、同じディレクトリに
a.jpg
とA.JPG
をこの順で作成するようにした場合- このプログラムを Windows 上で実行すると、
a.jpg
のうえにA.JPG
が上書きされてしまうため、A.JPG
として出力されるべき内容がa.jpg
に書き込まれ、a.jpg
として出力されるべき内容がどこにも残らない - 実装によっては
A.JPG
出力時にすでにa.jpg
が存在するためエラーになるという結果になるかもしれません
- このプログラムを Windows 上で実行すると、
このような事態を防ぐためには、ソースファイル名や出力ファイル名が決まる工程で、windows のファイルシステムの仕様に目を向ける必要があります。
所有ユーザ・所有グループの変更を windows で実行しない
Windows のファイルシステムでは、所有の扱いが Linux と異なります。そのため、python の os.chown()
は Windows 上では実行できずエラーになります。必要に応じて次の対策を検討しましょう。
対策: 仕様・設計を見直す
アプリケーションの仕様として必要な動作であれば、そもそも Windows 上での仕様の検討が不十分である可能性が考えられます。所有ユーザ・所有グループの変更が必要である理由に基づいて、Windows上でのあるべき動作を検討してください。(もしくは、Windows 上で動かすべきアプリケーションではないと割り切って諦めてください)
そのうえで、LinuxとWindowsで処理を分ける場合は、下記のように os.name
に応じて所有ユーザ・所有グループの変更の有無を制御します。
import os
if os.name == "posix":
os.chown(target_path, uid, gid)
else:
pass # 何らかの処理
対策: テストケースを linux 専用にする
もし、「所有ユーザ・所有グループが特定の状態であるケース」というテストケースの試験をするための準備として所有ユーザ・所有グループを変更したいということであれば、このテストは Linux 環境でしか実施できないので、そのようにマークしましょう。
たとえば、pytest の場合は @pytest.mark.skipif
を使う方法(おすすめ)と、カスタムマーク を使う方法があります。
import os
import sys
import pytest
# sys.platform が win で始まるとき、この試験はスキップされる
@pytest.mark.skipif(sys.platform.startswith("win"), reason="does not run on windows")
def test__illegal_owner(tmp_path):
tempfile_for_test = tmp_path / "tmp.txt"
tempfile_for_test.touch()
# テストの準備として所有ユーザを変更する
os.chown(tempfile_for_test, 1, 1)
# テスト対象の関数・メソッドを実行する
...
# カスタムマーク onlylinux を付けた例
# pytest 実行時の引数に -m 'not onlylinux' をつけると、この試験はスキップされる
@pytest.mark.onlylinux
def test__illegal_owner2(tmp_path):
...
パーミッションの変更をあてにしない
Windows のファイルシステムでは、権限の扱いが Linux と異なり、読み込み専用にするか否かのみ設定できます。そのため、python の os.chmod()
を Windows 上では実行した場合、所有ユーザが読み込み専用か否か以外の指定はすべて無視されます(エラーにはなりません)。
以上のことから、所有ユーザの書き込み権限を失わせる・与える変更であれば問題ないですが、それ以外の指定は windows では無視されることを前提に、下記のような検討を実施します。
対策: 仕様・設計を見直す
アプリケーションの仕様として必要な動作であれば、そもそも Windows 上での仕様の検討が不十分である可能性が考えられます。パーミッションの変更が必要である理由に基づいて、Windows上でのあるべき動作を検討してください。(もしくは、Windows 上で動かすべきアプリケーションではないと割り切って諦めてください)
そのうえで、Windows では所有ユーザの書き込み権限以外の指定を無視してよいということであれば、対処は不要です。Windowsでは別の処理をするということであれば、先述した所有ユーザ・所有グループの件と同様に os.name
を使用します。
対策: テストケースの実現方法を見直す
もし、「パーミッションが読み込み不可の状態であるケース」というテストケースの試験をするための準備として読み込み不可状態にしたいということであれば、単に「何らかの要因で読み込みできないケース」としても問題ないか検討しましょう。
「対象のファイルが存在しない(ため読み込めない)ケース」や「open()
等のモック化により読み込みを失敗させるケース」で代用できるのであれば、パーミッションの書き換えは不要になります。以下、pytest でモック化する例です。
from unittest.mock import patch
def test__read_failed(tmp_path):
tempfile_for_test = tmp_path / "tmp.txt"
# 何らかの方法で tempfile_for_test に内容を書き込んでおく
# open() をモック化し、実行時に例外を挙げるようにする
# open はビルトイン (import 無しで実行可能) なので、builtins モジュールに属している
with patch('builtins.open') as mock:
mock.side_effect = Exception("エラー")
# pytest.raises によって、例外が発生してもテストを続行できるように、
# また、例外が発生しないとテストが失敗するようにする。
with pytest.raises(Exception) as excinfo:
# テスト対象の関数やメソッドを実行する。
# その内部で open が実行されるとモック化により Exception("エラー") が raise される。
...
この例では、簡素な方法でモック化しています。「引数 mode="r"
のときのみ例外を出す」など詳細な制御やアサーションをするためには、よりたくさんのコードの記述かサードパーティライブラリが必要になります。
対策: テストケースを linux 専用にする
先述した所有ユーザ・所有グループの件と同様のため省略します。
テキストエンコード編
Linux では文字コードとして主に UTF-8 を使うのに対し、Windows では主に Shift-JIS を使用します。また、改行を意味する特殊文字として Linux では LF を使いますが、Windows では主に CR+LF (2文字) を使用します。これらの違いはシェルやコマンドプロンプトの入出力の違いというだけでなく、python においてテキストを入出力する際のデフォルト文字コード・改行コードの違いとしても現れます。
たとえば、次のコードでファイル入出力する際の文字コード・改行コードは OS によって異なります。
# 文字コードを encoding= で指定せず、改行コードも newline= で指定しない
with open("sample.txt", mode="w") as fp:
# linux なら UTF-8 で、windows なら Shift-JIS で出力される
# windows なら、\n (LF) が \r\n (CRLF) に変換され出力される
print("サンプル1\nサンプル2", file=fp)
# 文字コードを encoding= で指定せず、改行コードも newline= で指定しない
with open("sample2.txt") as fp:
# 文字コードがファイルと合っていないと文字化けしたりエラーになったりする
lines = fp.readlines()
このような問題が発生しないよう、いくつかの項目を設けます。
python 実行時のオプション -Xutf8
や環境変数 PYTHONUTF8=1
によって windows でも UTF-8 をデフォルトにすることができますが、ここでは「その指定をしなくても......」という話をします。
仕様で文字コード・改行コードが決まっているファイルの入出力時は文字コード・改行コードを明示する
最も単純なのが、入出力するファイルの仕様として、もしくは処理の仕様として文字コードが決まっているケースです。例えば次のようなケースです。
- 私たちが作るシステム独自のファイルAについて「UTF-8を用いる」「LFで改行する」と、私たちのシステムの仕様書に書いてある
- ユーザが指定した文字コード・改行コードで出力する
この場合は仕様通りの文字コード・改行コードを明示して読み書きすれば問題ありません。標準エンコーディング から目的の文字コードを探して使用します。
# sample.txt は UTF-8, LF で記述すると仕様で定まっている場合
with open("sample.txt", mode="w", encoding="utf_8", newline="\n") as fp:
print("サンプル", file=fp)
# ユーザが指定した文字コードを使用するという仕様であり、
# その文字コードが変数 specified_encoding に格納されている場合
# (改行コードは CRLF 固定という仕様である場合)
with open("sample2.txt", encoding=specified_encoding, newline="\r\n") as fp:
fp.readlines()
このケースが最も単純なので、このケースに落とし込むため、独自ファイルの仕様を定めるときは常に文字コード・改行コードを固定してしまうのも手です。
コンソールへの標準出力・標準エラー出力時は文字コード・改行コードを明示しない
コンソール出力の場合、open
を使わずに sys.stdout
や sys.stderr
に出力するため、通常であれば文字コードを指定しないはずですが、それでコンソールへ正常に表示できます。
ただし、windows でリダイレクト出力した際に UTF-8 以外で出力されることに不都合がある場合、「標準出力の代わりに指定のファイルへ出力することもできる機能」と「その際の文字コードを指定できる機能」を付けたうえで前述の「ユーザが指定した文字コードで出力する」ケースに落とし込んだ方がよいかもしれません。
連続する空白系の文字が無視される状況なら改行コードは気にしなくてよい
改行コードについては、一部、上述のような配慮が不要な場合があります。それは、連続する空白系の文字が無視される場合です。たとえば、JSON では改行が入る位置に空白系の文字がいくつくっついていても無視されるため、\r\n
と \n
を取り違えても結果は変わりません。
import json
# JSON においては改行コードの違いに意味がないのでテキトーでよい。
# なお Unicode 表現を使うルールを忠実に守っていることが保証されるなら、ASCII 文字だけなので文字コードもテキトーでよい。
with open("sample.json") as fp:
loaded = json.load(fp)
それ以外
設計段階でどうにかして先述の「入出力するファイルの仕様として、もしくは処理の仕様として文字コード・改行コードが決まっているケース」にする。
サブプロセス編
アプリケーションによっては subprocess.run
や subprocess.Popen
を使用してサブプロセスを使用するかもしれませんが、ここにもOSによる違いが現れます。
たとえば、subprocess.run(["ln, "-l"])
は linux では多くの場合動作します。環境変数 PATH
に、ls
のディレクトリパスが入っているためです。一方で Windows では通常は ls
(ls.exe
) へのパスが通っていないためエラーになります。このように、OSによって大抵パスが通っているコマンドに差異があるため、実行の成否が変わります。
また、引数 shell=True
を指定することでシェルを使用するようにした場合、Linux では bash
等が使用されるのに対して Windows では cmd.exe
(コマンドプロンプト) が使用されるため、ここでも差異が出ます。たとえば Windows ではコマンドプロンプトの機能である dir
コマンドを subprocess.run("dir", shell=True)
のように使用できますが、Linux では使えません。また、ワイルドカードの展開等もシェルの機能なので、OSによって (あるいは使用するシェルによって) 動作に差が出ます。
これらの動作の差を生まないために考慮が必要です。
極力 subprocess
以外を使用する
上の例では ls
, dir
を例に挙げましたが、これらの外部コマンドを subprocess
で呼び出さなくても、同じことが python の標準ライブラリで実現できます (使用できるオプションや出力フォーマットを完全に合わせようとすると面倒ですが)。このように python 内で完結させることで、subprocess 固有の問題を回避できます。
使用するコマンドが git
などアプリケーションである場合、標準ライブラリなどでの置き換えが難しいですが、たいていは置き換え不要です。たいていの場合、Windows にも Linux にもインストールできるアプリケーションであれば同じコマンド・引数に対して同じように動作するためです。
対策: os
, shutil
モジュールを使用する。
ファイル操作であれば、おおむねこれらのモジュールで同じことができます。
対策: sed
コマンドの代わりに re
モジュールを使用する。
re
は正規表現機能のモジュールです。open()
等基本的なファイル操作関数と組み合わせることで、sed
のような文字列置換を実現できます。
import re
# おおむね sed 's/abc[ad]/FOO/g' src.txt > dist.txt と同じ。
# もっと原始的に for 文を使ってもよい。
regex = re.compile("abc[ad]")
with open("src.txt") as src, open("dist.txt", mode="w") as dist:
dist.writelines(re.sub(regex, "FOO", line) for line in src)
置換以外の機能であれば、re
を使わずにより簡単に実現できます。
import re
# おおむね sed '3d' src.txt > dist.txt と同じ。
# もっと原始的に for 文と if 文を使ってもよい。
with open("src.txt") as src, open("dist.txt", mode="w") as dist:
dist.writelines(line for no, line in enumerate(src, 1) if no != 3)
対策: grep
コマンドの代わりに re
モジュールを使用する。
re
は正規表現機能のモジュールです。open()
等基本的なファイル操作関数と組み合わせることで、grep
のような文字列検索を実現できます。
import re
# おおむね grep -n -E 'abc[ad]' と同じ。
# もっと原始的に for 文と print 関数を使ってもよい。
regex = re.compile("abc[ad]")
with open("src.txt") as src:
sys.stdout.writelines(
f"{no}:{line}"
for no, line in enumerate(src, 1)
if regex.search(line)
)
コマンドに近づけるために標準出力へ出力していますが、もちろん内部で使うために戻り値として返すこともできます。
import re
import typing
# 検索にヒットした行についてタプル (行番号,行) を順に返すイテレータを生成する関数
# (fp をクローズするとイテレーションできなくなる点に注意)
def iter_grep(pattern: str, fp: typing.TextIO) -> typing.Iterator[typing.Tuple[int,str]]:
regex = re.compile(pattern)
yield from ((no, line) for no, line in enumerate(src, 1) if regex.search(line))
対策: ワイルドカード展開機能の代わりに glob
モジュールを使用する。
ワイルドカードでファイル名を探したい場合、python では glob
モジュールが使用できます。
import glob
# おおむね ls -1 *.txt と同じ。
# もっと原始的に for 文と print 関数を使ってもよい。
sys.stdout.writelines(path + "\n" for path in glob.iglob("*.txt"))
対策: os.name
, sys.platform
の値に応じて異なるパスを使用する
OSによってコマンドを変える必要がありかつ subprocess
以外での実現が難しい場合、OS に応じて動作を変える方法が使えます。そのためには os.name
もしくは sys.platform
を参照します。