◆ 親フォルダを .. で参照できない
◆ sys.path の追加が必要
◆ 相対パスできるような仕組みをつくってみた
  ◆ sys.path 以下しかインポートできないのでメインモジュールの場所は一番上の階層に固定
  ◆ そこからの「.」区切りパスに変換してインポート
  ◆ __main__ をインポートして その import_module 関数を使って相対パスでインポートする
◆ 相対パスにしなくてもパッケージルートからのパスでインポートで十分そう

Python のインポート

こういうフォルダ構造があるとします

- a.py
- d/
- m.py
- b.py
- s/
- c.py

m.py がメインのファイルで これを実行します

python3 m.py

m.py では a.py, b.py, c.py を import したいです

b.py と c.py は単純にこれでインポートできます

import b
import s.c

print(b.name)
print(s.c.name)

「import」 の後に書くのは相対パスではなく sys.path のパスから探すパッケージ名です
最初に実行したメインモジュールがあるフォルダは sys.path に追加されます
なので m.py と同じフォルダの b.py は b という名前でインポートできます
また フォルダ s の中の c.py は s.c でインポートできます

sys.path から見つけられるパッケージの内側のモジュールはアクセスできますが sys.path にあるパスより外側はアクセスできません
from を使って 「..」 を指定することでパス的には親の指定ができます
しかし Python3 からは相対パスで親フォルダの指定は禁止されたようです

やってみても

from .. import a

ImportError: attempted relative import with no known parent package

というエラーです
インポートするためには sys.path を追加するしかないです

import sys
import os
import b
import s.c

sys.path.append(os.path.abspath(".."))

import a

print(a.name)
print(b.name)
print(s.c.name)
A
B
C

a.py があるのは m.py から一つ親なので 「..」 の abspath を sys.path に追加しました
こうした場合 a.py があるフォルダも m.py があるフォルダも sys.path に存在します
そうなると b.py は d.b という名前でも b という名前でもインポートできます

import d.b
import b

print(d.b.__file__ == b.__file__)
print(d.b)
print(b)
True
<module 'd.b' from 'C:\\Users\\winuser\\code\\562\\d\\b.py'>
<module 'b' from 'C:\\Users\\winuser\\code\\562\\d\\b.py'>

こういうことができるので インポートする .py ファイルを含む全部のフォルダを sys.path に追加するのは良い方法ではありません
ファイル名が重複する問題もあります

相対パスでインポートする

そういう面倒なのをなくして Node.js のように相対パスでインポートできるようにしたいです
良い方法ないかなと考えていて こういうものを作ってみました

[m.py]
import os
import importlib

base_path = os.path.dirname(os.path.abspath(__file__))

def import_module(path, file):
dir = os.path.dirname(file)
module_path = os.path.join(dir, path)
rel = os.path.relpath(module_path, base_path)
if rel.startswith(".."):
raise Exception("module path is out of root, " + rel)
return importlib.import_module(rel.replace(os.sep, "."))

###

c = import_module("s/c", __file__)
print("c", c)

[c.py]
import __main__

b = __main__.import_module("../b", __file__)
print("b", b)

>py m.py
b <module 'b' from 'C:\\Users\\winuser\\code\\562\\d\\b.py'>
c <module 's.c' from 'C:\\Users\\winuser\\code\\562\\d\\s\\c.py'>

m.py が s/c.py をインポートして s/c.py が b.py をインポートします

メインの m.py に import_module 関数を作っていて これを使います
1 つ目の引数には相対パスを 2 つ目の引数には __file__ を渡します

__file__ は相対パスの解決用です
実際のインポート処理を m.py で行うので 別階層だと相対パスが変わります
インポート時に abspath とか使って絶対パスに固定するのも面倒なので __file__ を渡す形にしました

import_module は c.py のように __main__ をインポートして使います
__main__ はメインのモジュールを指すので m.py になります

sys.path は自動で追加されるメインモジュールのフォルダのみで手動で追加はしません
メインモジュールからの相対パスを 「.」 区切りの s.c などに変換してインポートします
つまり これだと a.py はインポートできません
メインモジュールより上の階層のモジュールをインポートしなくて良いように最も上の階層にメインモジュールを配置します
m.py 自体の位置を変えたくないなら a.py の階層に main.py を作って main.py から m.py をインポートするように変更します

こうすることで 自分のファイルからの相対パスで親を含むモジュールをインポートできるようになりました

パッケージ

色々工夫しましたが 準備が面倒なのが欠点です
import_module 関数を定義したメインモジュールをトップ階層に配置して それをエントリポイントとして起動しないといけないですし

もっとシンプルにできていたならともかく ここまで来ると相対パスにこだわって特殊なことをするよりパッケージとして考えて パッケージルートからのパスでインポートするほうが楽だと思いました

- main.py
- dir1/
- f1.py
- dir2/
- f2.py

こういうフォルダ構造で main.py が f2.py をインポートして f2.py が f1.py をインポートします

[main.py]
import dir1.dir2.f2

[f2.py]
import dir1.f1

[f1.py]
print("loaded")

>py main.py
loaded

main.py が f2.py をインポートするときは dir1.dir2.f2
f2.py が f1.py をインポートするときは dir1.f1

という指定です

main.py があるフォルダが sys.path に含まれているので そこからのパスとします
自分から相対パスだから 「..」 という考え方はしません

この場合も sys.path に手動追加をしなくて済むように main.py はそれより上の階層を見なくて良いパッケージルートに配置します