From 4bc7af8c727a41b153066ae75078040d0fd3f79b Mon Sep 17 00:00:00 2001 From: Toshiya MORI Date: Tue, 10 Sep 2024 03:48:35 +0900 Subject: [PATCH 1/9] =?UTF-8?q?api.py=E3=82=92PAT=E3=81=AB=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C=E3=81=97=E3=81=A6=E6=97=A2=E5=AD=98=E3=81=AE=E3=83=86?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=81=8C=E9=80=9A=E3=82=8B=E3=81=A8=E3=81=93?= =?UTF-8?q?=E3=82=8D=E3=81=BE=E3=81=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- annofabapi/api.py | 89 ++++++++++++++++++++++++------------ annofabapi/credentials.py | 48 +++++++++++++++++++ annofabapi/resource.py | 5 +- annofabapi/util/type_util.py | 6 +++ tests/test_local_resource.py | 6 +-- 5 files changed, 122 insertions(+), 32 deletions(-) create mode 100644 annofabapi/credentials.py create mode 100644 annofabapi/util/type_util.py diff --git a/annofabapi/api.py b/annofabapi/api.py index 98bfa183..63cccb09 100644 --- a/annofabapi/api.py +++ b/annofabapi/api.py @@ -4,15 +4,17 @@ 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,33 @@ 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コードを標準入力から入力するかどうか 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 if isinstance(credentials, IdPass) else credentials self.cookies: Optional[RequestsCookieJar] = None self.__account_id: Optional[str] = None + self.__user_id: Optional[str] = None class _MyToken(AuthBase): """ @@ -328,8 +329,9 @@ def _create_kwargs( "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)): @@ -616,7 +618,7 @@ def _request_get_with_cookie(self, project_id: str, url: str) -> requests.Respon ######################################### # 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 +631,7 @@ def _login_respond_to_auth_challenge(self, mfa_code: str, session: str) -> Dict[ 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 +647,7 @@ def _login_respond_to_auth_challenge(self, mfa_code: str, session: str) -> Dict[ 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 +673,15 @@ def login(self, mfa_code: Optional[str] = None) -> None: 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(self.credentials, mfa_code) + elif isinstance(self.credentials, Pat): + return + else: + assert_noreturn(self.credentials) + + def _login(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 +693,21 @@ def login(self, mfa_code: Optional[str] = None) -> None: 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 logout(self) -> None: """ ログアウトします。 - ログアウト後は、インスタンス変数 ``token_dict`` をNoneにします。 + ログアウト後は、インスタンス変数 ``tokens`` をNoneにします。 @@ -708,27 +718,33 @@ def logout(self) -> None: 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 +753,12 @@ def refresh_token(self) -> None: 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 +771,19 @@ def account_id(self) -> str: 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 00000000..2d3bdfd7 --- /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 62bbec65..c0ef6a68 100644 --- a/annofabapi/resource.py +++ b/annofabapi/resource.py @@ -6,6 +6,7 @@ from annofabapi import AnnofabApi, AnnofabApi2, Wrapper from annofabapi.api import DEFAULT_ENDPOINT_URL +from annofabapi.credentials import IdPass from annofabapi.exceptions import CredentialsNotFoundError logger = logging.getLogger(__name__) @@ -32,7 +33,9 @@ def __init__( self, login_user_id: str, login_password: str, *, 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=IdPass(user_id=login_user_id, password=login_password), + endpoint_url=endpoint_url, + input_mfa_code_via_stdin=input_mfa_code_via_stdin, ) self.wrapper = Wrapper(self.api) diff --git a/annofabapi/util/type_util.py b/annofabapi/util/type_util.py new file mode 100644 index 00000000..1097c290 --- /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は str(x)と等価 diff --git a/tests/test_local_resource.py b/tests/test_local_resource.py index 5d78fc00..2786836a 100644 --- a/tests/test_local_resource.py +++ b/tests/test_local_resource.py @@ -7,6 +7,7 @@ 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 @@ def test_build_with_endpoint(self): 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) From 46268b24d9d7b0c21860e6924729d71c0be4c520 Mon Sep 17 00:00:00 2001 From: Toshiya MORI Date: Tue, 10 Sep 2024 05:04:33 +0900 Subject: [PATCH 2/9] =?UTF-8?q?=E7=92=B0=E5=A2=83=E5=A4=89=E6=95=B0?= =?UTF-8?q?=E3=83=BB=E5=BC=95=E6=95=B0=E3=81=ABPAT=E3=82=92=E6=8C=87?= =?UTF-8?q?=E5=AE=9A=E3=81=97=E3=81=A6=E3=82=A4=E3=83=B3=E3=82=B9=E3=82=BF?= =?UTF-8?q?=E3=83=B3=E3=82=B9=E3=82=92=E7=94=9F=E6=88=90=E3=81=A7=E3=81=8D?= =?UTF-8?q?=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB=E3=81=97=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- annofabapi/api.py | 4 ++-- annofabapi/resource.py | 53 ++++++++++++++++++++++++++---------------- tests/test_build.py | 31 ++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 22 deletions(-) create mode 100644 tests/test_build.py diff --git a/annofabapi/api.py b/annofabapi/api.py index 63cccb09..4937e2fd 100644 --- a/annofabapi/api.py +++ b/annofabapi/api.py @@ -238,7 +238,7 @@ class AnnofabApi(AbstractAnnofabApi): Args: credentials: Annofabにログインするときの認証情報 endpoint_url: Annofab APIのエンドポイント。 - input_mfa_code_via_stdin: MFAコードを標準入力から入力するかどうか + input_mfa_code_via_stdin: MFAコードを標準入力から入力するかどうか Falseの時にMFAコードの入力を求められた場合は例外を送出する Attributes: tokens: login, refresh_tokenで取得したtoken情報 @@ -246,7 +246,7 @@ class AnnofabApi(AbstractAnnofabApi): """ 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): + 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.") diff --git a/annofabapi/resource.py b/annofabapi/resource.py index c0ef6a68..bbe5c146 100644 --- a/annofabapi/resource.py +++ b/annofabapi/resource.py @@ -1,12 +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 +from annofabapi.credentials import IdPass, Pat from annofabapi.exceptions import CredentialsNotFoundError logger = logging.getLogger(__name__) @@ -20,7 +20,7 @@ class Resource: login_user_id: AnnofabにログインするときのユーザID login_password: Annofabにログインするときのパスワード endpoint_url: Annofab APIのエンドポイント。 - input_mfa_code_via_stdin: MFAコードを標準入力から入力するかどうか + input_mfa_code_via_stdin: MFAコードを標準入力から入力するかどうか Falseの時にMFAコードの入力を求められた場合は例外を送出する Attributes: api: ``annofabapi.AnnofabApi`` のインスタンス @@ -29,11 +29,9 @@ 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( - credentials=IdPass(user_id=login_user_id, password=login_password), + credentials=credentials, endpoint_url=endpoint_url, input_mfa_code_via_stdin=input_mfa_code_via_stdin, ) @@ -42,12 +40,14 @@ def __init__( 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, @@ -55,16 +55,18 @@ 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コードを標準入力から入力するかどうか + input_mfa_code_via_stdin: MFAコードを標準入力から入力するかどうか Falseの時にMFAコードの入力を求められた場合は例外を送出する Returns: AnnofabApi, Wrapperのインスタンスを保持するインスタンス @@ -73,10 +75,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: @@ -94,7 +100,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コードを標準入力から入力するかどうか + input_mfa_code_via_stdin: MFAコードを標準入力から入力するかどうか Falseの時にMFAコードの入力を求められた場合は例外を送出する Returns: annofabapi.Resourceインスタンス @@ -119,16 +125,17 @@ 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コードを標準入力から入力するかどうか + input_mfa_code_via_stdin: MFAコードを標準入力から入力するかどうか Falseの時にMFAコードの入力を求められた場合は例外を送出する Returns: annofabapi.Resourceインスタンス @@ -138,7 +145,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/tests/test_build.py b/tests/test_build.py new file mode 100644 index 00000000..0eb82b00 --- /dev/null +++ b/tests/test_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" From 2e999120f3e4894b0c2381fb10745f26f408485a Mon Sep 17 00:00:00 2001 From: Toshiya MORI Date: Tue, 10 Sep 2024 05:14:24 +0900 Subject: [PATCH 3/9] =?UTF-8?q?AnnofabApi=E3=82=92=E3=81=84=E3=81=8F?= =?UTF-8?q?=E3=81=A4=E3=81=8B=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Unauthorize時のlogin&retry処理を、IdPassが渡されていた時のみにした * Patを渡された場合のlogin時、self.tokensにPatを転写するようにした * logoutの処理と対象になるように --- annofabapi/api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/annofabapi/api.py b/annofabapi/api.py index 4937e2fd..9ea124ec 100644 --- a/annofabapi/api.py +++ b/annofabapi/api.py @@ -514,8 +514,8 @@ def _request_wrapper( }, ) - # 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, @@ -676,7 +676,7 @@ def login(self, mfa_code: Optional[str] = None) -> None: if isinstance(self.credentials, IdPass): self._login(self.credentials, mfa_code) elif isinstance(self.credentials, Pat): - return + self.tokens = self.credentials else: assert_noreturn(self.credentials) From 471e2433111970f743ea5c6451cf43eb08cff4a0 Mon Sep 17 00:00:00 2001 From: Toshiya MORI Date: Tue, 10 Sep 2024 06:27:25 +0900 Subject: [PATCH 4/9] =?UTF-8?q?create=5Ftest=5Fproject.py=E3=81=8Cendpoint?= =?UTF-8?q?=E3=82=92=E5=BC=95=E6=95=B0=E3=81=AB=E5=8F=96=E3=82=8C=E3=82=8B?= =?UTF-8?q?=E3=82=88=E3=81=86=E3=81=AB=E3=81=97=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/create_test_project.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/create_test_project.py b/tests/create_test_project.py index 824e3ea3..ce0f27c1 100644 --- a/tests/create_test_project.py +++ b/tests/create_test_project.py @@ -8,6 +8,7 @@ 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) From 8f044b8a5906b7f433452a9baf15047d55d42383 Mon Sep 17 00:00:00 2001 From: Toshiya MORI Date: Tue, 10 Sep 2024 06:28:27 +0900 Subject: [PATCH 5/9] =?UTF-8?q?Pat=E3=82=92credentials=E3=81=AB=E5=88=A9?= =?UTF-8?q?=E7=94=A8=E3=81=99=E3=82=8B=E5=A0=B4=E5=90=88=E3=81=AEtokens?= =?UTF-8?q?=E3=81=B8=E3=81=AE=E8=BB=A2=E5=86=99=E3=82=92=E3=82=B3=E3=83=B3?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=83=A9=E3=82=AF=E3=82=BF=E3=81=A7=E3=81=AF?= =?UTF-8?q?=E3=81=AA=E3=81=8F=E3=83=AA=E3=82=AF=E3=82=A8=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=82=92=E5=AE=9F=E8=A1=8C=E3=81=99=E3=82=8B=E7=9B=B4=E5=89=8D?= =?UTF-8?q?=E3=81=AB=E8=A1=8C=E3=81=86=E3=82=88=E3=81=86=E3=81=AB=E3=81=97?= =?UTF-8?q?=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- annofabapi/api.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/annofabapi/api.py b/annofabapi/api.py index 9ea124ec..0c1349bc 100644 --- a/annofabapi/api.py +++ b/annofabapi/api.py @@ -257,7 +257,7 @@ def __init__(self, credentials: Union[IdPass, Pat], *, endpoint_url: str = DEFAU self.url_prefix = f"{endpoint_url}/api/v1" self.session = requests.Session() - self.tokens: Union[Tokens, Pat, None] = None if isinstance(credentials, IdPass) else credentials + self.tokens: Union[Tokens, Pat, None] = None self.cookies: Optional[RequestsCookieJar] = None @@ -497,6 +497,12 @@ def _request_wrapper( 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情報を出力する。 @@ -674,13 +680,13 @@ def login(self, mfa_code: Optional[str] = None) -> None: MfaEnabledUserExecutionError: ``self.input_mfa_code_via_stdin`` が ``False`` AND ``mfa_code`` が未指定の場合 """ if isinstance(self.credentials, IdPass): - self._login(self.credentials, mfa_code) + self._login_id_pass(self.credentials, mfa_code) elif isinstance(self.credentials, Pat): - self.tokens = self.credentials + self._login_pat(self.credentials) else: assert_noreturn(self.credentials) - def _login(self, id_pass: IdPass, mfa_code: Optional[str] = None) -> None: + 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" @@ -704,6 +710,9 @@ def _login(self, id_pass: IdPass, mfa_code: Optional[str] = None) -> None: 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: """ ログアウトします。 From d44fc731adc1c21849cf88a62097454f8bcb3e25 Mon Sep 17 00:00:00 2001 From: Toshiya MORI Date: Tue, 10 Sep 2024 06:37:42 +0900 Subject: [PATCH 6/9] =?UTF-8?q?README.md=E3=82=92=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ce8ee8c8..e8938617 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` From c19f62e3b1d467199aeda65a48a90d3c5f4598dc Mon Sep 17 00:00:00 2001 From: Toshiya MORI Date: Wed, 11 Sep 2024 21:22:01 +0900 Subject: [PATCH 7/9] =?UTF-8?q?=E6=AD=A3=E3=81=97=E3=81=8F=E3=81=AA?= =?UTF-8?q?=E3=81=84=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- annofabapi/util/type_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/annofabapi/util/type_util.py b/annofabapi/util/type_util.py index 1097c290..d7199cc3 100644 --- a/annofabapi/util/type_util.py +++ b/annofabapi/util/type_util.py @@ -3,4 +3,4 @@ def assert_noreturn(x: NoReturn) -> NoReturn: """python 3.11 以降に追加されたassert_neverの代わり""" - raise AssertionError(f"Invalid value: {x!r}") # x!rは str(x)と等価 + raise AssertionError(f"Invalid value: {x!r}") # x!rは repr(x)と等価 From 06eff375199774f78ab638867568575cf5c99f81 Mon Sep 17 00:00:00 2001 From: Toshiya MORI Date: Wed, 11 Sep 2024 21:27:34 +0900 Subject: [PATCH 8/9] =?UTF-8?q?input=5Fmfa=5Fcode=5Fvia=5Fstdin=20?= =?UTF-8?q?=E3=81=AE=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- annofabapi/api.py | 3 ++- annofabapi/resource.py | 12 ++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/annofabapi/api.py b/annofabapi/api.py index 0c1349bc..c152e7b9 100644 --- a/annofabapi/api.py +++ b/annofabapi/api.py @@ -238,7 +238,8 @@ class AnnofabApi(AbstractAnnofabApi): Args: credentials: Annofabにログインするときの認証情報 endpoint_url: Annofab APIのエンドポイント。 - input_mfa_code_via_stdin: MFAコードを標準入力から入力するかどうか Falseの時にMFAコードの入力を求められた場合は例外を送出する + input_mfa_code_via_stdin: MFAコードを標準入力から入力するかどうか + Falseを渡して且つMFAコードの入力を求められるアカウントを利用する場合、mfa_codeを引数にloginメソッドを直接呼び出さなければならず、そうしない場合は例外を送出する Attributes: tokens: login, refresh_tokenで取得したtoken情報 diff --git a/annofabapi/resource.py b/annofabapi/resource.py index bbe5c146..8eb68563 100644 --- a/annofabapi/resource.py +++ b/annofabapi/resource.py @@ -20,7 +20,8 @@ class Resource: login_user_id: AnnofabにログインするときのユーザID login_password: Annofabにログインするときのパスワード endpoint_url: Annofab APIのエンドポイント。 - input_mfa_code_via_stdin: MFAコードを標準入力から入力するかどうか Falseの時にMFAコードの入力を求められた場合は例外を送出する + input_mfa_code_via_stdin: MFAコードを標準入力から入力するかどうか + Falseを渡して且つMFAコードの入力を求められるアカウントを利用する場合、mfa_codeを引数にloginメソッドを直接呼び出さなければならず、そうしない場合は例外を送出する Attributes: api: ``annofabapi.AnnofabApi`` のインスタンス @@ -66,7 +67,8 @@ def build( login_password: Annofabにログインするときのパスワード pat: パーソナルアクセストークン。 この値を渡した場合、login_user_idとlogin_passwordは無視される endpoint_url: Annofab APIのエンドポイント。 - input_mfa_code_via_stdin: MFAコードを標準入力から入力するかどうか Falseの時にMFAコードの入力を求められた場合は例外を送出する + input_mfa_code_via_stdin: MFAコードを標準入力から入力するかどうか + Falseを渡して且つMFAコードの入力を求められるアカウントを利用する場合、mfa_codeを引数にloginメソッドを直接呼び出さなければならず、そうしない場合は例外を送出する Returns: AnnofabApi, Wrapperのインスタンスを保持するインスタンス @@ -100,7 +102,8 @@ 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コードの入力を求められた場合は例外を送出する + input_mfa_code_via_stdin: MFAコードを標準入力から入力するかどうか + Falseを渡して且つMFAコードの入力を求められるアカウントを利用する場合、mfa_codeを引数にloginメソッドを直接呼び出さなければならず、そうしない場合は例外を送出する Returns: annofabapi.Resourceインスタンス @@ -135,7 +138,8 @@ def build_from_env(*, endpoint_url: str = DEFAULT_ENDPOINT_URL, input_mfa_code_v Args: endpoint_url: Annofab APIのエンドポイント。 - input_mfa_code_via_stdin: MFAコードを標準入力から入力するかどうか Falseの時にMFAコードの入力を求められた場合は例外を送出する + input_mfa_code_via_stdin: MFAコードを標準入力から入力するかどうか + Falseを渡して且つMFAコードの入力を求められるアカウントを利用する場合、mfa_codeを引数にloginメソッドを直接呼び出さなければならず、そうしない場合は例外を送出する Returns: annofabapi.Resourceインスタンス From c0db97a0d84815d3b895a54162a844fd9f267226 Mon Sep 17 00:00:00 2001 From: Toshiya MORI Date: Wed, 11 Sep 2024 21:29:11 +0900 Subject: [PATCH 9/9] rename test_build.py to test_local_build.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CIでのテスト対象とするため --- tests/{test_build.py => test_local_build.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_build.py => test_local_build.py} (100%) diff --git a/tests/test_build.py b/tests/test_local_build.py similarity index 100% rename from tests/test_build.py rename to tests/test_local_build.py