Repository: kurusugawa-computer/annofab-api-python-client

Issue:
パーソナルアクセストークン対応
Annofabに追加されるパーソナルアクセストークンに対応する

Discussion:

Patch:
diff --git a/README.md b/README.md
index ce8ee8c..e893861 100644
--- a/README.md
+++ b/README.md
@@ -65,6 +65,19 @@ password = "YYYYYY"
 service = build(user_id, password)
 ```
 
+### PersonalAccessTokenをコンストラクタ引数に渡す場合
+
+```python
+# APIアクセス用のインスタンスを生成
+from annofabapi import build
+
+
+pat = "XXXXXX"
+
+service = build(pat = pat)
+```
+
+
 ### `.netrc`に認証情報を記載する場合
 `.netrc`ファイルに、AnnofabのユーザIDとパスワードを記載します。
 
@@ -91,7 +104,13 @@ service = build_from_netrc()
 
 
 ### 環境変数に認証情報を設定する場合
-環境変数`ANNOFAB_USER_ID`、`ANNOFAB_PASSWORD`にユーザIDとパスワードを設定します。
+
+
+* IDとパスワードで認証する場合
+    * 環境変数`ANNOFAB_USER_ID`、`ANNOFAB_PASSWORD`にユーザIDとパスワードを設定します。
+* パーソナルアクセストークンで認証する場合
+    * 環境変数`ANNOFAB_PAT`にトークンを設定します。
+    * `ANNOFAB_PAT`が設定されている場合、`ANNOFAB_USER_ID`、`ANNOFAB_PASSWORD`は無視されます。
 
 ```python
 from annofabapi import build_from_env
@@ -109,6 +128,8 @@ service = build()
 
 優先順位は以下の通りです。
 1. 環境変数
+    1. `ANNOFAB_PAT`
+    2. `ANNOFAB_USER_ID`及び`ANNOFAB_PASSWORD`
 2. `.netrc`
 
 
diff --git a/annofabapi/api.py b/annofabapi/api.py
index 98bfa18..c152e7b 100644
--- a/annofabapi/api.py
+++ b/annofabapi/api.py
@@ -4,15 +4,17 @@ import logging
 import time
 from functools import wraps
 from json import JSONDecodeError
-from typing import Any, Callable, Collection, Dict, Optional, Tuple
+from typing import Any, Callable, Collection, Dict, Optional, Tuple, Union
 
 import backoff
 import requests
 from requests.auth import AuthBase
 from requests.cookies import RequestsCookieJar
 
+from annofabapi.credentials import IdPass, Pat, Tokens
 from annofabapi.exceptions import InvalidMfaCodeError, MfaEnabledUserExecutionError, NotLoggedInError
 from annofabapi.generated_api import AbstractAnnofabApi
+from annofabapi.util.type_util import assert_noreturn
 
 logger = logging.getLogger(__name__)
 
@@ -234,34 +236,34 @@ class AnnofabApi(AbstractAnnofabApi):
     Web APIに対応したメソッドが存在するクラス。
 
     Args:
-        login_user_id: AnnofabにログインするときのユーザID
-        login_password: Annofabにログインするときのパスワード
+        credentials: Annofabにログインするときの認証情報
         endpoint_url: Annofab APIのエンドポイント。
         input_mfa_code_via_stdin: MFAコードを標準入力から入力するかどうか
+                                  Falseを渡して且つMFAコードの入力を求められるアカウントを利用する場合、mfa_codeを引数にloginメソッドを直接呼び出さなければならず、そうしない場合は例外を送出する
 
     Attributes:
-        token_dict: login, refresh_tokenで取得したtoken情報
+        tokens: login, refresh_tokenで取得したtoken情報
         cookies: Signed Cookie情報
     """
 
-    def __init__(
-        self, login_user_id: str, login_password: str, *, endpoint_url: str = DEFAULT_ENDPOINT_URL, input_mfa_code_via_stdin: bool = False
-    ) -> None:
-        if not login_user_id or not login_password:
+    def __init__(self, credentials: Union[IdPass, Pat], *, endpoint_url: str = DEFAULT_ENDPOINT_URL, input_mfa_code_via_stdin: bool = False) -> None:
+        if isinstance(credentials, IdPass) and (not credentials.user_id or not credentials.password):
             raise ValueError("login_user_id or login_password is empty.")
+        if isinstance(credentials, Pat) and not credentials.token:
+            raise ValueError("pat is empty.")
 
-        self.login_user_id = login_user_id
-        self.login_password = login_password
+        self.credentials = credentials
         self.endpoint_url = endpoint_url
         self.input_mfa_code_via_stdin = input_mfa_code_via_stdin
         self.url_prefix = f"{endpoint_url}/api/v1"
         self.session = requests.Session()
 
-        self.token_dict: Optional[Dict[str, Any]] = None
+        self.tokens: Union[Tokens, Pat, None] = None
 
         self.cookies: Optional[RequestsCookieJar] = None
 
         self.__account_id: Optional[str] = None
+        self.__user_id: Optional[str] = None
 
     class _MyToken(AuthBase):
         """
@@ -328,8 +330,9 @@ class AnnofabApi(AbstractAnnofabApi):
             "params": new_params,
             "headers": headers,
         }
-        if self.token_dict is not None:
-            kwargs.update({"auth": self._MyToken(self.token_dict["id_token"])})
+        if self.tokens is not None:
+            token = self.tokens.auth_token
+            kwargs.update({"auth": self._MyToken(token)})
 
         if request_body is not None:
             if isinstance(request_body, (dict, list)):
@@ -495,6 +498,12 @@ class AnnofabApi(AbstractAnnofabApi):
         else:
             url = f"{self.url_prefix}{url_path}"
 
+        # patを使う場合は最初にtokensをセットする
+        # def logoutの呼び出しでtokensがNoneになった後にAPIを呼び出しても問題ないように（IdPassの場合も、自動loginしているので、その代わり）
+        # IdPassと同じ処理に合流させてしまうと、patが無効なときに無限ループしてしまうので、ここで1回だけ呼び出す
+        if self.tokens is None and isinstance(self.credentials, Pat):
+            self._login_pat(self.credentials)
+
         kwargs = self._create_kwargs(query_params, header_params, request_body)
         response = self.session.request(method=http_method.lower(), url=url, **kwargs)
         # response.requestよりメソッド引数のrequest情報の方が分かりやすいので、メソッド引数のrequest情報を出力する。
@@ -512,8 +521,8 @@ class AnnofabApi(AbstractAnnofabApi):
             },
         )
 
-        # Unauthorized Errorならば、ログイン後に再度実行する
-        if response.status_code == requests.codes.unauthorized:
+        # ID/PASSが指定されており、Unauthorized Errorならば、ログイン後に再度実行する
+        if isinstance(self.credentials, IdPass) and response.status_code == requests.codes.unauthorized:
             self.refresh_token()
             return self._request_wrapper(
                 http_method,
@@ -616,7 +625,7 @@ class AnnofabApi(AbstractAnnofabApi):
     #########################################
     # Public Method : Login
     #########################################
-    def _login_respond_to_auth_challenge(self, mfa_code: str, session: str) -> Dict[str, Any]:
+    def _login_respond_to_auth_challenge(self, id_pass: IdPass, mfa_code: str, session: str) -> Dict[str, Any]:
         """
         MFAコードによるログインを実行します。
 
@@ -629,7 +638,7 @@ class AnnofabApi(AbstractAnnofabApi):
         Raises:
             InvalidMfaCodeError: ``self.input_mfa_code_via_stdin`` が ``False`` AND ``mfa_code`` が正しくない場合
         """
-        request_body = {"user_id": self.login_user_id, "mfa_code": mfa_code, "session": session}
+        request_body = {"user_id": id_pass.user_id, "mfa_code": mfa_code, "session": session}
         url = f"{self.url_prefix}/login-respond-to-auth-challenge"
 
         response = self._execute_http_request("post", url, json=request_body, raise_for_status=False)
@@ -645,7 +654,7 @@ class AnnofabApi(AbstractAnnofabApi):
                 if self.input_mfa_code_via_stdin:
                     logger.info(new_error_message)
                     new_mfa_code = _read_mfa_code_from_stdin()
-                    return self._login_respond_to_auth_challenge(new_mfa_code, session)
+                    return self._login_respond_to_auth_challenge(id_pass, new_mfa_code, session)
                 else:
                     raise InvalidMfaCodeError(new_error_message)
 
@@ -671,7 +680,15 @@ class AnnofabApi(AbstractAnnofabApi):
             InvalidMfaCodeError: ``self.input_mfa_code_via_stdin`` が ``False`` AND ``mfa_code`` が正しくない場合
             MfaEnabledUserExecutionError: ``self.input_mfa_code_via_stdin`` が ``False`` AND ``mfa_code`` が未指定の場合
         """
-        login_info = {"user_id": self.login_user_id, "password": self.login_password}
+        if isinstance(self.credentials, IdPass):
+            self._login_id_pass(self.credentials, mfa_code)
+        elif isinstance(self.credentials, Pat):
+            self._login_pat(self.credentials)
+        else:
+            assert_noreturn(self.credentials)
+
+    def _login_id_pass(self, id_pass: IdPass, mfa_code: Optional[str] = None) -> None:
+        login_info = {"user_id": id_pass.user_id, "password": id_pass.password}
 
         url = f"{self.url_prefix}/login"
 
@@ -683,21 +700,24 @@ class AnnofabApi(AbstractAnnofabApi):
                 if self.input_mfa_code_via_stdin:
                     mfa_code = _read_mfa_code_from_stdin()
                 else:
-                    raise MfaEnabledUserExecutionError(self.login_user_id)
+                    raise MfaEnabledUserExecutionError(id_pass.user_id)
 
-            mfa_json_obj = self._login_respond_to_auth_challenge(mfa_code, login_json_obj["session"])
+            mfa_json_obj = self._login_respond_to_auth_challenge(id_pass, mfa_code, login_json_obj["session"])
             token_dict = mfa_json_obj["token"]
         else:
             # `login` APIのレスポンスのスキーマがloginRespondToAuthChallengeのとき
             token_dict = login_json_obj["token"]
 
-        self.token_dict = token_dict
-        logger.debug("Logged in successfully. user_id = %s", self.login_user_id)
+        self.tokens = Tokens.from_dict(token_dict)
+        logger.debug("Logged in successfully. user_id = %s", id_pass.user_id)
+
+    def _login_pat(self, pat: Pat) -> None:
+        self.tokens = pat
 
     def logout(self) -> None:
         """
         ログアウトします。
-        ログアウト後は、インスタンス変数 ``token_dict`` をNoneにします。
+        ログアウト後は、インスタンス変数 ``tokens`` をNoneにします。
 
 
 
@@ -708,27 +728,33 @@ class AnnofabApi(AbstractAnnofabApi):
             NotLoggedInError: ログインしてない状態で関数を呼び出したときのエラー
         """
 
-        if self.token_dict is None:
+        if self.tokens is None:
             raise NotLoggedInError
+        if isinstance(self.tokens, Pat):
+            self.tokens = None
+            return
 
-        request_body = self.token_dict
+        request_body = self.tokens.to_dict()
         url = f"{self.url_prefix}/logout"
         self._execute_http_request("POST", url, json=request_body)
-        self.token_dict = None
+        self.tokens = None
 
     def refresh_token(self) -> None:
         """
         トークンを再発行して、新しいトークン情報をインスタンスに保持します。
+        パーソナルアクセストークンでのアクセスをしている場合はrefreshを行いません。
         ログインしていない場合やリフレッシュトークンの有効期限が切れている場合は、login APIを実行して新しいトークン情報をインスタンスに保持します。
 
         """
 
-        if self.token_dict is None:
+        if self.tokens is None:
             # 一度もログインしていないときは、login APIを実行して、トークン情報をインスタンスに保持する（login関数内でインスタンスに保持している）
             self.login()
             return
+        if isinstance(self.tokens, Pat):
+            return
 
-        request_body = {"refresh_token": self.token_dict["refresh_token"]}
+        request_body = {"refresh_token": self.tokens.refresh_token}
         url = f"{self.url_prefix}/refresh-token"
         response = self._execute_http_request("POST", url, json=request_body)
 
@@ -737,11 +763,12 @@ class AnnofabApi(AbstractAnnofabApi):
             self.login()
             return
 
-        self.token_dict = response.json()
+        self.tokens = Tokens.from_dict(response.json())
 
     #########################################
     # Public Method : Other
     #########################################
+
     @property
     def account_id(self) -> str:
         """
@@ -754,3 +781,19 @@ class AnnofabApi(AbstractAnnofabApi):
             account_id = content["account_id"]
             self.__account_id = account_id
             return account_id
+
+    @property
+    def login_user_id(self) -> str:
+        """
+        Annofabにログインするユーザのuser_id
+        """
+        if self.__user_id is not None:
+            return self.__user_id
+        if isinstance(self.credentials, IdPass):
+            self.__user_id = self.credentials.user_id
+            return self.__user_id
+        else:
+            content, _ = self.get_my_account()
+            user_id = content["user_id"]
+            self.__user_id = user_id
+            return user_id
diff --git a/annofabapi/credentials.py b/annofabapi/credentials.py
new file mode 100644
index 0000000..2d3bdfd
--- /dev/null
+++ b/annofabapi/credentials.py
@@ -0,0 +1,48 @@
+from dataclasses import dataclass
+from typing import Dict, Protocol
+
+
+class HasAuthToken(Protocol):
+    @property
+    def auth_token(self) -> str: ...
+
+
+@dataclass(frozen=True)
+class IdPass:
+    user_id: str
+    password: str
+
+
+@dataclass(frozen=True)
+class Pat(HasAuthToken):
+    """Personal Access Token"""
+
+    token: str
+
+    @property
+    def auth_token(self) -> str:
+        return f"Bearer {self.token}"
+
+
+@dataclass(frozen=True)
+class Tokens(HasAuthToken):
+    """IdPassを元にログインしたあとに取得されるトークン情報"""
+
+    id_token: str
+    access_token: str
+    refresh_token: str
+
+    @property
+    def auth_token(self) -> str:
+        return self.id_token
+
+    def to_dict(self) -> Dict[str, str]:
+        return {
+            "id_token": self.id_token,
+            "access_token": self.access_token,
+            "refresh_token": self.refresh_token,
+        }
+
+    @staticmethod
+    def from_dict(d: Dict[str, str]) -> "Tokens":
+        return Tokens(id_token=d["id_token"], access_token=d["access_token"], refresh_token=d["refresh_token"])
diff --git a/annofabapi/resource.py b/annofabapi/resource.py
index 62bbec6..8eb6856 100644
--- a/annofabapi/resource.py
+++ b/annofabapi/resource.py
@@ -1,11 +1,12 @@
 import logging
 import netrc
 import os
-from typing import Optional
+from typing import Optional, Union
 from urllib.parse import urlparse
 
 from annofabapi import AnnofabApi, AnnofabApi2, Wrapper
 from annofabapi.api import DEFAULT_ENDPOINT_URL
+from annofabapi.credentials import IdPass, Pat
 from annofabapi.exceptions import CredentialsNotFoundError
 
 logger = logging.getLogger(__name__)
@@ -20,6 +21,7 @@ class Resource:
         login_password: Annofabにログインするときのパスワード
         endpoint_url: Annofab APIのエンドポイント。
         input_mfa_code_via_stdin: MFAコードを標準入力から入力するかどうか
+                                  Falseを渡して且つMFAコードの入力を求められるアカウントを利用する場合、mfa_codeを引数にloginメソッドを直接呼び出さなければならず、そうしない場合は例外を送出する
 
     Attributes:
         api: ``annofabapi.AnnofabApi`` のインスタンス
@@ -28,23 +30,25 @@ class Resource:
 
     """
 
-    def __init__(
-        self, login_user_id: str, login_password: str, *, endpoint_url: str = DEFAULT_ENDPOINT_URL, input_mfa_code_via_stdin: bool = False
-    ) -> None:
+    def __init__(self, credentials: Union[IdPass, Pat], *, endpoint_url: str = DEFAULT_ENDPOINT_URL, input_mfa_code_via_stdin: bool = False) -> None:
         self.api = AnnofabApi(
-            login_user_id=login_user_id, login_password=login_password, endpoint_url=endpoint_url, input_mfa_code_via_stdin=input_mfa_code_via_stdin
+            credentials=credentials,
+            endpoint_url=endpoint_url,
+            input_mfa_code_via_stdin=input_mfa_code_via_stdin,
         )
 
         self.wrapper = Wrapper(self.api)
 
         self.api2 = AnnofabApi2(self.api)
 
-        logger.debug("Create annofabapi resource instance :: %s", {"login_user_id": login_user_id, "endpoint_url": endpoint_url})
+        id_or_token = credentials.user_id if isinstance(credentials, IdPass) else "PersonalAccessToken"
+        logger.debug("Create annofabapi resource instance :: %s", {"user_id_or_token": id_or_token, "endpoint_url": endpoint_url})
 
 
 def build(
     login_user_id: Optional[str] = None,
     login_password: Optional[str] = None,
+    pat: Optional[str] = None,
     *,
     endpoint_url: str = DEFAULT_ENDPOINT_URL,
     input_mfa_code_via_stdin: bool = False,
@@ -52,16 +56,19 @@ def build(
     """
     AnnofabApi, Wrapperのインスタンスを保持するインスタンスを生成する。
 
-    ``login_user_id`` と ``login_password`` の両方がNoneの場合は、``.netrc`` ファイルまたは環境変数から認証情報を取得する。
+    ``pat``が渡された場合はそれが優先して利用される。
+    ``pat`` / ``login_user_id`` / ``login_password`` の全てがNoneの場合は、``.netrc`` ファイルまたは環境変数から認証情報を取得する。
     認証情報は、環境変数, ``.netrc`` ファイルの順に読み込む。
 
-    環境変数は``ANNOFAB_USER_ID`` , ``ANNOFAB_PASSWORD`` を参照する。
+    環境変数は``ANNOFAB_USER_ID`` , ``ANNOFAB_PASSWORD``, ``ANNOFAB_PAT`` を参照し、``ANNOFAB_PAT``が設定されている場合はそれ以外を無視する。
 
     Args:
         login_user_id: AnnofabにログインするときのユーザID
         login_password: Annofabにログインするときのパスワード
+        pat: パーソナルアクセストークン。 この値を渡した場合、login_user_idとlogin_passwordは無視される
         endpoint_url: Annofab APIのエンドポイント。
         input_mfa_code_via_stdin: MFAコードを標準入力から入力するかどうか
+                                  Falseを渡して且つMFAコードの入力を求められるアカウントを利用する場合、mfa_codeを引数にloginメソッドを直接呼び出さなければならず、そうしない場合は例外を送出する
 
     Returns:
         AnnofabApi, Wrapperのインスタンスを保持するインスタンス
@@ -70,10 +77,14 @@ def build(
         CredentialsNotFoundError: `.netrc`ファイルまたは環境変数にAnnofabの認証情報がなかった
 
     """
+    if pat is not None:
+        return Resource(credentials=Pat(pat), endpoint_url=endpoint_url, input_mfa_code_via_stdin=input_mfa_code_via_stdin)
     if login_user_id is not None and login_password is not None:
-        return Resource(login_user_id, login_password, endpoint_url=endpoint_url, input_mfa_code_via_stdin=input_mfa_code_via_stdin)
+        return Resource(
+            credentials=IdPass(login_user_id, login_password), endpoint_url=endpoint_url, input_mfa_code_via_stdin=input_mfa_code_via_stdin
+        )
 
-    elif login_user_id is None and login_password is None:
+    elif login_user_id is None and login_password is None and pat is None:
         try:
             return build_from_env(endpoint_url=endpoint_url, input_mfa_code_via_stdin=input_mfa_code_via_stdin)
         except CredentialsNotFoundError:
@@ -92,6 +103,7 @@ def build_from_netrc(*, endpoint_url: str = DEFAULT_ENDPOINT_URL, input_mfa_code
     Args:
         endpoint_url: Annofab APIのエンドポイント。
         input_mfa_code_via_stdin: MFAコードを標準入力から入力するかどうか
+                                  Falseを渡して且つMFAコードの入力を求められるアカウントを利用する場合、mfa_codeを引数にloginメソッドを直接呼び出さなければならず、そうしない場合は例外を送出する
 
     Returns:
         annofabapi.Resourceインスタンス
@@ -116,16 +128,18 @@ def build_from_netrc(*, endpoint_url: str = DEFAULT_ENDPOINT_URL, input_mfa_code
     if login_user_id is None or login_password is None:
         raise CredentialsNotFoundError("User ID or password in the .netrc file are None.")
 
-    return Resource(login_user_id, login_password, endpoint_url=endpoint_url, input_mfa_code_via_stdin=input_mfa_code_via_stdin)
+    return Resource(credentials=IdPass(login_user_id, login_password), endpoint_url=endpoint_url, input_mfa_code_via_stdin=input_mfa_code_via_stdin)
 
 
 def build_from_env(*, endpoint_url: str = DEFAULT_ENDPOINT_URL, input_mfa_code_via_stdin: bool = False) -> Resource:
     """
-    環境変数 ``ANNOFAB_USER_ID`` , ``ANNOFAB_PASSWORD`` から、annofabapi.Resourceインスタンスを生成する。
+    環境変数 ``ANNOFAB_USER_ID`` , ``ANNOFAB_PASSWORD``, ``ANNOFAB_PAT`` から、annofabapi.Resourceインスタンスを生成する。
+    ``ANNOFAB_PAT``が設定されている場合はそれが優先して利用される。
 
     Args:
         endpoint_url: Annofab APIのエンドポイント。
         input_mfa_code_via_stdin: MFAコードを標準入力から入力するかどうか
+                                  Falseを渡して且つMFAコードの入力を求められるアカウントを利用する場合、mfa_codeを引数にloginメソッドを直接呼び出さなければならず、そうしない場合は例外を送出する
 
     Returns:
         annofabapi.Resourceインスタンス
@@ -135,7 +149,13 @@ def build_from_env(*, endpoint_url: str = DEFAULT_ENDPOINT_URL, input_mfa_code_v
     """
     login_user_id = os.environ.get("ANNOFAB_USER_ID")
     login_password = os.environ.get("ANNOFAB_PASSWORD")
-    if login_user_id is None or login_password is None:
-        raise CredentialsNotFoundError("`ANNOFAB_USER_ID` or `ANNOFAB_PASSWORD`  environment variable are empty.")
+    pat = os.environ.get("ANNOFAB_PAT")
+
+    if pat is not None:
+        return Resource(credentials=Pat(pat), endpoint_url=endpoint_url, input_mfa_code_via_stdin=input_mfa_code_via_stdin)
+    if login_user_id is not None and login_password is not None:
+        return Resource(
+            credentials=IdPass(login_user_id, login_password), endpoint_url=endpoint_url, input_mfa_code_via_stdin=input_mfa_code_via_stdin
+        )
 
-    return Resource(login_user_id, login_password, endpoint_url=endpoint_url, input_mfa_code_via_stdin=input_mfa_code_via_stdin)
+    raise CredentialsNotFoundError("`ANNOFAB_PAT` and `ANNOFAB_USER_ID / ANNOFAB_PASSWORD` environment variable are empty.")
diff --git a/annofabapi/util/type_util.py b/annofabapi/util/type_util.py
new file mode 100644
index 0000000..d7199cc
--- /dev/null
+++ b/annofabapi/util/type_util.py
@@ -0,0 +1,6 @@
+from typing import NoReturn
+
+
+def assert_noreturn(x: NoReturn) -> NoReturn:
+    """python 3.11 以降に追加されたassert_neverの代わり"""
+    raise AssertionError(f"Invalid value: {x!r}")  # x!rは repr(x)と等価


Test patch:
diff --git a/tests/create_test_project.py b/tests/create_test_project.py
index 824e3ea..ce0f27c 100644
--- a/tests/create_test_project.py
+++ b/tests/create_test_project.py
@@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional
 from more_itertools import first_true
 
 import annofabapi
+from annofabapi.api import DEFAULT_ENDPOINT_URL
 from annofabapi.models import TaskPhase
 
 logger = logging.getLogger(__name__)
@@ -291,6 +292,7 @@ def parse_args():
         default=DEFAULT_PROJECT_TITLE,
         help="作成するプロジェクトのタイトルを指定してください。",
     )
+    parser.add_argument("--endpoint", type=str, default=DEFAULT_ENDPOINT_URL, help="接続先エンドポイントを指定してください")
 
     return parser.parse_args()
 
@@ -307,7 +309,7 @@ def main() -> None:
 
     args = parse_args()
 
-    main_obj = CreatingTestProject(annofabapi.build())
+    main_obj = CreatingTestProject(annofabapi.build(endpoint_url=args.endpoint))
     main_obj.main(organization_name=args.organization, project_title=args.project_title)
 
 
diff --git a/tests/test_local_build.py b/tests/test_local_build.py
new file mode 100644
index 0000000..0eb82b0
--- /dev/null
+++ b/tests/test_local_build.py
@@ -0,0 +1,31 @@
+import os
+
+import pytest
+
+from annofabapi.credentials import IdPass, Pat
+from annofabapi.resource import build
+
+
+class Test引数なしでbuildした時:
+    @pytest.fixture(autouse=True)
+    def clear_env(self):
+        os.environ.pop("ANNOFAB_USER_ID", None)
+        os.environ.pop("ANNOFAB_PASSWORD", None)
+        os.environ.pop("ANNOFAB_PAT", None)
+        yield
+
+    def test_環境変数ANNOFAB_PATが設定されている場合はredentialsがPatになっていること(self):
+        os.environ["ANNOFAB_PAT"] = "DUMMY_PAT"
+        os.environ["ANNOFAB_USER_ID"] = "DUMMY_USER_ID"
+        os.environ["ANNOFAB_PASSWORD"] = "DUMMY_PASSWORD"
+        resource = build()
+        assert isinstance(resource.api.credentials, Pat)
+        assert resource.api.credentials.token == "DUMMY_PAT"
+
+    def test_環境変数ANNOFAB_USER_IDとANNOFAB_PASSWORDのみが設定されている場合はredentialsがIdPathになっていること(self):
+        os.environ["ANNOFAB_USER_ID"] = "DUMMY_USER_ID"
+        os.environ["ANNOFAB_PASSWORD"] = "DUMMY_PASSWORD"
+        resource = build()
+        assert isinstance(resource.api.credentials, IdPass)
+        assert resource.api.credentials.user_id == "DUMMY_USER_ID"
+        assert resource.api.credentials.password == "DUMMY_PASSWORD"
diff --git a/tests/test_local_resource.py b/tests/test_local_resource.py
index 5d78fc0..2786836 100644
--- a/tests/test_local_resource.py
+++ b/tests/test_local_resource.py
@@ -7,6 +7,7 @@ import os
 import pytest
 
 import annofabapi.exceptions
+from annofabapi.credentials import IdPass
 from annofabapi.resource import build, build_from_env
 
 
@@ -17,7 +18,7 @@ class TestBuild:
 
     def test_raise_ValueError(self):
         with pytest.raises(ValueError):
-            annofabapi.AnnofabApi("test_user", "")
+            annofabapi.AnnofabApi(IdPass("test_user", ""))
 
     def test_build_from_env_raise_CredentialsNotFoundError(self):
         with pytest.raises(annofabapi.exceptions.CredentialsNotFoundError):
@@ -49,5 +50,4 @@ class TestBuild:
         resource = build(user_id, password, endpoint_url="https://localhost:8080")
         assert resource.api.url_prefix == "https://localhost:8080/api/v1"
         assert resource.api2.url_prefix == "https://localhost:8080/api/v2"
-        assert resource.api.login_user_id == user_id
-        assert resource.api.login_password == password
+        assert resource.api.credentials == IdPass(user_id, password)