Module
출처: 파이썬 공식문서 > The Python Tutorial > 6. 모듈
모듈에 대한 깔삼한 설명이 있어 하나 들고와봤다. 파이썬 인터프리터를 종료한 후에 다시 들어가면 이전에 만들어놨던 함수나 변수들이 사라진다. 하지만 여러 프로그램에서 썼던 편리한 함수를 각 프로그램에 정의를 복사하지 않고도 사용하고 싶을 때 파이썬은 정의들을 파일에 넣고 스크립트나 인터프리터의 대화형 모드에서 사용할 수 있는 방법을 제공한다. 그런 파일을 모듈이라고 부른다. 모듈 검색은 다음 경로로 진행된다. 예를들어 spam이란 이름의 모듈이 import 되었다고 했을 때, (1) sys.buildin_module_names
를 통해 파이썬 인터프리터에서 컴파일된 모듈들에 있는지 확인하고 (2) 1에서 검색되지 않는다면, sys.path
가 지정한 디렉토리 목록에서 실행한 모듈의 파일을 검색한다. sys.path
는 파이썬에서 모듈을 찾을 때 참조하는 경로들의 리스트를 저장하는 속성이다. 이를통해 파이썬은 어떤 디렉토리에서 모듈을 찾아야하는지 알 수 있다. sys.path
가 초기화될 때 다음 경로들이 모두 포함된다. 파이썬 스크립트를 실행할 때 그 스크립트가 위치한 디렉토리가 sys.path의 첫번째 위치로 추가된다. PYTHONPATH 환경변수에 정의된 디렉토리들도 sys.path에 추가된다. 그리고 site-packeages와 같은 특정 기본 경로들이 sys.path에 추가된다.
The import system
열받아서 도저히 안되겠다. LlamaIndex의 Upstage Embedding을 좀 쓰려고 하는데 계속 upstage_api_key
가 없다고 pydantic validation 에러가 떠서, 수정하고 PR을 보내려고 로컬에 LlamaIndex와 llama-index-embeddings-upstage
을 editable 모드로 로컬에 설치하여 테스트를 돌려보려는데 계속 upstage 패키지를 찾지 못하는 것이었다. llama_index/embedding
위치에 openai와 upstage 디렉토리가 존재하는데 openai 모듈은 검색이 되고 upstage 모듈은 검색이 되지 않는 것이었다. from llama_index.embedding
까지는 모듈을 잘 찾는데, from llama_index.embedding.upstage
는 못찾는 것이었다. 근데 또 이상했던건 from llama_index.embedding.openai
모듈은 또 잘 찾았다. 그리고 openai 패키지를 삭제하니까 upstage 모듈을 임포트 할 수 있었는데, 그 원인이 도저히 이해가 가지 않았다. 도대체 파이썬에서는 어떻게 패키지와 모듈을 구성하고 import를 하는 것일까?
모듈이 처음 임포트 될 때, 파이썬은 모듈을 검색하고, 검색된다면 모듈 객체를 만들고 초기화한다. 반대로 검색되지 않는다면 ModuleNotFoundError를 일으킨다. 임포트 절차는 크게 검색한 후 모듈 객체를 만드는 것으로 요약할 수 있는데 자세한 과정을 뒤에서 알아본다. importlib 모듈은 임포트 시스템과 상호작용하기 위한 api를 제공한다. 임포트 과정에서 사용되는 함수들은 모두 importlib 내에 정의된 함수들이다.
패키지
파이썬에는 한가지 종류의 모듈 객체만 갖고 있고, 모든 모듈은 파이썬, C 또는 다른 언어로 구현되었는지 여부에 관계없이 이 한가지 종류의 모듈 객체로 표현된다. 모듈을 조직화하고 naming의 계층구조를 제공하기 위해 python은 패키지 개념을 도입한다. package도 파이썬 모듈인데 __path__
속성을 가지고 있는 파이썬 모듈이라고 생각하면 편하다.
패키지는 두가지로 분류할 수 있다. regular package(정규 패키지)와 namespace package(네임스페이스 패키지)가 있는데, regular package는 일반적으로 __init__.py
가 포함된 디렉토리로 regular package가 임포트 될 때 __init__.py
는 implicitly 하게 실행된다. 예를들어, 다음과 같은 파일시스템 배치는 최상위 parent 패키지와 세 개의 서브 패키지를 정의한다.
parent/
__init__.py
one/
__init__.py
two/
__init__.py
three/
__init__.py
parent.one
을 임포트하면 parent/__init__.py
과 parent/one/__init__.py
가 implicitly 하게 실행된다. 뒤이은 parent.two
와 parent.three
의 임포트는 각각 parent/two/__init__.py
와 parent/three/__init__.py
를 실행한다.
그 다음으로 네임스페이스 패키지는 PEP420에 정의되어있는데, 하위 패키지를 위한 컨테이너 역할만 하는 PEP420 패키지이다. 네임스페이스 패키지는 __init__.py
파일이 없기 때문에 일반 패키지와는 다르다. 모든 모듈에는 이름이 있는데 하위 패키지 이름은 부모 패지키 이름과 점으로 구분된다. 예를들어 email이란 패키지 안에 email.mime라는 하위 패키지가 있고 하위 패키지 안에 email.mime.text라는 모듈이 있을 수 있다. 네임스페이스 패키지는 파일 시스템의 객체와 직접 대응할 수도 있고 그렇지 않을 수도 있으며, 구체적인 표현이 없는 가상 모듈일 수도 있다. 네임스페이스 패키지는 특히 대규모 프로젝트나 여러 팀이 같은 패키지 이름을 사용해야할 때 유용하다. 코드를 더 모듈화하고 쉽게 확장할 수 있다. 정규 패키지는 하나의 디렉토리로 구성되지만 네임스페이스 패키지는 여러 디렉토리로 분산될 수 있다. 또한 네임스페이스 패키지는 경로가 변경될 때마다 자동으로 다시 검색되는 유연한 구조를 가지고 있다. 실제로 llama_index가 네임스페이스 패키지의 형태를 띄고 있다. 각자 서로 다른 위치에 있지만 llama_index라는 동일한 이름의 네임스페이스 패키지에서 동작한다.
검색
모듈 검색을 크게 요약해서 설명을 하자면 특정 모듈을 검색할 때 가장 먼저 sys.modules을 살펴보고 없다면 파인더를 통해 모듈이 검색한다. 검색이 되었다면 로더가 포함된 모듈 스펙을 반환하고 로더를 통해 모듈을 로딩하고, 검색이 되지 않았다면 ModuleNotFoundError를 발생시킨다. 그럼 좀 더 자세히 살펴보자.
임포트하기 위한 검색을 할 때 가장 먼저 체크하는 곳이 sys.modules이다. 이전에 intermidate path를 포함해서 이전에 임포트 되었던 모듈들이 캐시 되어있다. 파이썬은 임포트된 모듈을 sys.modules에 캐시하여, 성능을 최적화한다. sys.modules에서 키를 삭제하면 캐시가 무효화되어 다음 임포트 시 새로운 검색이 일어난다. sys.modules에 없다면 파이썬 임포트는 모듈을 find 와 load 하도록 호출한다. 이 프로토콜은 두개의 conceptual object로 구성되어 있는데 finders(파인더)와 loaders(로더)이다. 그리고 파인더와 로더를 모두 구현한 객체를 importer(임포터)라고 한다. 파인더는 모듈 이름에 해당하는 모듈을 찾을 수 있는지 판단하고 찾을 수 있다면 해당 모듈에 대한 정보를 담은 module spec(모듈 스펙)을 반환한다. 자세한 모듈 스펙은 링크 를 참고하자. 로더는 모듈 스펙을 사용하여 해당 모듈을 메모리에 로드하고 초기화한다. 모듈 스펙에는 모듈의 import와 관련된 모든 정보를 포함하고 있고 로더도 가지고 있다. 즉 파인더가 반환하는 모듈 스펙 내에 로더가 포함되어 있다는 것이다. 파인더는 실제로 모듈을 로드하지 않고 주어진 이름의 모듈을 찾으면 임포트와 관련된 정보들을 요약한 모듈 스펙(module spec)을 반환하는데 임포트 프로세스는 모듈을 로딩할 때 이 모듈 스펙을 사용하게된다.
주어진 이름의 모듈을 sys.modules 에서 찾을 수 없을 때 그 이후 과정을 좀 더 자세히 살펴보자. 파이썬은 sys.meta_path
를 검색하는데, 메타 경로 파인더 객체들을 포함하고 있다. 메타 경로 파인더들은 importlib.abc.MetaPathFinder
를 상속받아 구현된 형태로 존재하며 find_spec()
메서드를 구현해야만 하는데, 이름, 임포트 경로, 타겟 모듈 이 세 개의 인자를 받는다. 만약 메타 경로 파인더가 주어진 이름의 모듈을 찾을 수 있다면 모듈 스펙 객체를 반환하고 찾을 수 없다면 None 을 반환한다. 만약 sys.meta_path
에 존재하는 모든 메타 경로 파인더들이 모듈 스펙을 하나라도 반환하지 못하면 ModuleNotFoundError 를 일으킨다. 그리고 그 순간 모든 임포트 프로세스를 중단한다. 즉 find_spec()
메서드는 모듈을 찾을 수 있으면 모듈 스펙(module spec) 객체를 반환하고 찾을 수 없으면 None을 반환하며, 메타 패스에 등록된 모든 파인더가 None을 반환하면 Python은 ModuleNotFoundError를 발생시킨다.
메타 경로 파인더는 모듈의 이름, 임포트 경로를 인자로 받는데 첫번째는 임포트하려는 모듈의 전체 이름이고, 두번째는 모듈 검색에 사용할 경로 목록이다. 서브모듈이나 서브패키지의 경우에는 부모 패키지의 __path__
속성 값이 전달되고 top-level 모듈이면 두번째 인자에 None을 넣는다. meta path는 하나의 임포트 요청에 대해 여러번 traversed 될 수 있다. 예를 들어, foo.bar.baz를 임포트한다고 가정할 때, 파이썬은 먼저 최상위 모듈 foo를 찾기 위해 sys.meta_path의 모든 파인더에서 find_spec("foo", None, None)
을 호출한다. foo 모듈이 임포트되면, 이제 foo.bar 모듈을 임포트하기 위해 find_spec("foo.bar", foo.__path__, None)
을 호출한다. 마지막으로 foo.bar.baz를 임포트하기 위해 find_spec("foo.bar.baz", foo.bar.__path__, None)
이 호출된다. 메타 경로는 한 번의 임포트 요청에 대해 여러 번 탐색 될 수 있다.
파이썬에는 몇가지 기본 파인더들과 임포터들을 sys.meta_path
에 메타 경로 파인더의 형태로 가지고 있다. 기본 파인더는 build-in modules를 찾을 수 있고 sys나 os가 이에 해당된다. 그리고 모듈이 다른 언어(ex. c언어) 로 컴파일되어 실행 파일에 포함된 frozen modules을 찾을 수도 있고 sys.path에 나열된 경로에서 모듈을 찾을 수도 있다. 파이썬의 임포트 시스템은 확장 가능하며 새로운 파인더를 추가하여 파이썬의 모듈 검색 범위와 방법을 확장할 수 있다. 예를들어 네트워크 상의 특정 위치에서 모듈을 찾아 로드하도록 파인더를 만들 수도 있다. 즉, 파이썬의 sys.meta_path은 기본적으로 세가지 메타 패스 파인터가 포함되어있다. 내장 모듈 파인더, 동결된 모듈 파인더, 경로 기반 파인더(<class '_frozen_importlib_external.PathFinder'>
)가 포함되어있다. 여기서 경로 기반 파인더롤 좀 더 자세히 살펴보자. 앞에서 말했듯이 파이썬은 여러 기본 메타 경로 파인더를 가지고 있다. 이중 하나는 경로 기반파인더(PathFinder)라고 불리는데, 일반적으로 sys.path를 검색한다. 경로 기반 파인더는 메타 경로 파인더의 일종이기 때문에 경로 기반 파인더도 find_spec()
메소드를 구현한다. 아래는 실제로 sys.meta_path
를 프린트해서 찍어본 결과이다.
[
<_distutils_hack.DistutilsMetaFinder object at 0x102d8a1c0>,
<_virtualenv._Finder object at 0x102d68940>,
<class '_frozen_importlib.BuiltinImporter'>,
<class '_frozen_importlib.FrozenImporter'>,
<class '_frozen_importlib_external.PathFinder'>
]
llamaindex.embeddings
에 대한 find_spec()
메소드를 실행시켜본 결과, openai embedding 만 나오는 것을 확인했다. 근데 분명 llamaindex
경로에 대한 find_spec()
을 호출하면 submodule_search_locations 인자에 추가한 embedding 모듈이 모두 검색되었다.
# ModuleSpec(
# name='llama_index',
# loader=None,
# submodule_search_locations=_NamespacePath(
# [
# '/{Localpath}/llama_index/llama-index-integrations/embeddings/llama-index-embeddings-premai/llama_index',
# '/{Localpath}/llama_index/_llama-index/llama_index',
# '/{Localpath}/llama_index/llama-index-integrations/embeddings/llama-index-embeddings-openai/llama_index',
# '/{Localpath}/llama_index/llama-index-integrations/embeddings/llama-index-embeddings-textembed/llama_index',
# '/{Localpath}/llama_index/llama-index-integrations/embeddings/llama-index-embeddings-upstage/llama_index',
# '/{Localpath}/llama_index/.venv/lib/python3.9/site-packages/llama_index']
# )
# )
llama_index/embeddings
디렉토리에는 __init__.py
파일이 있었고 이게 우선적으로 검색되었다. loader, origin, submodule이 모두 정의되어있음을 알 수 있다.
# ModuleSpec(
# name='llama_index.embeddings',
# loader=<_frozen_importlib_external.SourceFileLoader object at 0x1031d4490>,
# origin='/{Localpath}/llama_index/llama-index-integrations/embeddings/llama-index-embeddings-openai/llama_index/embeddings/__init__.py',
# submodule_search_locations=[
# '/{Localpath}/llama_index/llama-index-integrations/embeddings/llama-index-embeddings-openai/llama_index/embeddings'
# ]
# )
llama_index/embeddings
디렉토리의 __init__.py
을 삭제하면 어떻게 될까? 다음처럼 출력된다. loader와 origin에는 값이 없고 submodule_search_locations에 NamespacePath 객체의 형태로 출력된다.
# ModuleSpec(
# name='llama_index.embeddings',
# loader=None,
# submodule_search_locations=_NamespacePath(
# [
# '/{Localpath}/llama_index/llama-index-integrations/embeddings/llama-index-embeddings-premai/llama_index/embeddings',
# '/{Localpath}/llama_index/llama-index-integrations/embeddings/llama-index-embeddings-openai/llama_index/embeddings',
# '/{Localpath}/llama_index/llama-index-integrations/embeddings/llama-index-embeddings-textembed/llama_index/embeddings',
# '/{Localpath}/llama_index/llama-index-integrations/embeddings/llama-index-embeddings-upstage/llama_index/embeddings'
# ]
# )
# )
Stackoverflow namespace vs regular package
에 의하면 동일한 이름의 namespace 패키지를 통합할 수 있다고 한다. 둘 중 하나가 패키지 __init__.py
를 단일 네임스페이스로 통합할 수 있다고 하는데, 둘 중 하나가 패키지 __init__.py
가 되면 다른 디렉토리가 무시되므로 더 이상 통합을 지원할 수 없다고 한다. 반대로 둘다 __init__.py
가 있는 경우는 sys.path의 첫번째 경로가 사용된다고 한다.
로딩
모듈 스펙이 반환되면 모듈 스펙 내에 정의되어있는 로더를 사용하여 모듈을 생성하고 sys.modules에 저장한다. 아래는 임포트의 로딩 과정동안 일어나는 과정을 간략하게 코드로 작성한 것이다. spec.loader.exec_modules(module)
은 모듈의 네임스페이스가 채워지는 로딩의 핵심순간이다. 로딩 중에 생성되어 exec_module 메소드에 전달된 모듈은 import의 끝에서 반환된 모듈이 아닐 수도 있다.
module = None
if spec.loader is not None and hasattr(spec.loader, 'create_module'):
# It is assumed 'exec_module' will also be defined on the loader.
module = spec.loader.create_module(spec)
if module is None:
module = ModuleType(spec.name)
# The import-related module attributes get set here:
_init_module_attrs(spec, module)
if spec.loader is None:
# unsupported
raise ImportError
if spec.origin is None and spec.submodule_search_locations is not None:
# namespace package
sys.modules[spec.name] = module
elif not hasattr(spec.loader, 'exec_module'):
module = spec.loader.load_module(spec.name)
else:
sys.modules[spec.name] = module
try:
spec.loader.exec_module(module)
except BaseException:
try:
del sys.modules[spec.name]
except KeyError:
pass
raise
return sys.modules[spec.name]
패키지 상대 임포트
absolute import의 경우에 import <module>
또는 from <module> import <submodule>
형태의 구문을 사용할 수 있는데 relative import는 from <module> import <submodule>
형태의 구문만 사용 가능하다.
요약
위 내용은 llamaindex에서 업스테이지 임베딩을 사용하려고 하면서 생긴 이슈들을 해결하는 과정에서 찾아본 것들이다. 위에서 공부한 내용을 가지고 #15764 이슈를 해결하여 업스테이지 임베딩을 로컬에서 디버깅할 수 있었고, #15767 이슈를 해결하여 llamaindex에서 업스테이지 임베딩을 사용할 수 있도록 했다.
참고로 파이썬에는 너무나 다양한 패키지 매니저가 있다. 그리고 가상환경을 관리해주는 라이브러리는 따로 존재한다. 특정 패키지 매니저를 배포하고 싶을 때는 또 라이브러리를 설치해야한다. 가상환경, 패키지 매니저, 패키지 배포 매니저(?) 가 다 따로따로 있는게 솔직히 너무 불편하고 기회되면 이 부분도 한번 뜯어보고 다른 언어들도 한번 살펴보자. 참고자료는 다음과 같다 python package user guide , 가상환경 및 패키지 문서