pyinaturalist 1.0.0.dev0__tar.gz → 1.0.0.dev2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/PKG-INFO +2 -2
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/__init__.py +1 -4
- pyinaturalist-1.0.0.dev2/pyinaturalist/client/__init__.py +31 -0
- {pyinaturalist-1.0.0.dev0/pyinaturalist → pyinaturalist-1.0.0.dev2/pyinaturalist/client}/client.py +123 -13
- pyinaturalist-1.0.0.dev2/pyinaturalist/client/oauth.py +337 -0
- pyinaturalist-1.0.0.dev2/pyinaturalist/client/oauth_callback.py +278 -0
- {pyinaturalist-1.0.0.dev0/pyinaturalist → pyinaturalist-1.0.0.dev2/pyinaturalist/client}/paginator.py +4 -4
- {pyinaturalist-1.0.0.dev0/pyinaturalist → pyinaturalist-1.0.0.dev2/pyinaturalist/client}/session.py +121 -23
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/constants.py +80 -17
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/__init__.py +1 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/annotation_controller.py +52 -5
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/base_controller.py +1 -1
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/identification_controller.py +8 -6
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/observation_controller.py +14 -9
- pyinaturalist-1.0.0.dev2/pyinaturalist/controllers/observation_field_controller.py +103 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/place_controller.py +4 -1
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/project_controller.py +7 -1
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/search_controller.py +1 -1
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/taxon_controller.py +65 -6
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/user_controller.py +7 -1
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/docs/templates.py +34 -19
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/formatters.py +4 -89
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/controlled_term.py +14 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/observation.py +12 -3
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/taxon.py +1 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v0/observation_fields.py +1 -1
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v0/observations.py +1 -1
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/__init__.py +0 -1
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/controlled_terms.py +1 -1
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/identifications.py +1 -2
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/messages.py +1 -1
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/observation_fields.py +1 -1
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/observations.py +2 -39
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/places.py +1 -2
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/posts.py +1 -1
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/projects.py +9 -2
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/search.py +1 -1
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/taxa.py +1 -2
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/users.py +1 -1
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v2/observations.py +1 -2
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v2/taxa.py +1 -2
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyproject.toml +17 -17
- pyinaturalist-1.0.0.dev0/pyinaturalist/auth.py +0 -165
- pyinaturalist-1.0.0.dev0/pyinaturalist/node_api.py +0 -22
- pyinaturalist-1.0.0.dev0/pyinaturalist/rest_api.py +0 -25
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/.gitignore +0 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/LICENSE +0 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/README.md +0 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinat/__init__.py +0 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/converters.py +0 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/docs/__init__.py +0 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/docs/docstrings.py +0 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/docs/emoji.py +0 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/docs/model_docs.py +0 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/docs/signatures.py +0 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/exceptions.py +0 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/__init__.py +0 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/base.py +0 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/checklist.py +0 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/conservation_status.py +0 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/identification.py +0 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/lazy_property.py +0 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/media.py +0 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/message.py +0 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/observation_field.py +0 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/place.py +0 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/project.py +0 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/search.py +0 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/user.py +0 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/py.typed +0 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/request_params.py +0 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v0/__init__.py +0 -0
- {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v2/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: pyinaturalist
|
|
3
|
-
Version: 1.0.0.
|
|
3
|
+
Version: 1.0.0.dev2
|
|
4
4
|
Summary: iNaturalist API client for python
|
|
5
5
|
Project-URL: homepage, https://github.com/pyinat/pyinaturalist
|
|
6
6
|
Project-URL: repository, https://github.com/pyinat/pyinaturalist
|
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
# ruff: noqa: F401, F403
|
|
2
2
|
# isort: skip_file
|
|
3
|
-
from pyinaturalist.
|
|
4
|
-
from pyinaturalist.client import iNatClient
|
|
3
|
+
from pyinaturalist.client import *
|
|
5
4
|
from pyinaturalist.constants import *
|
|
6
5
|
from pyinaturalist.formatters import enable_logging, format_table, pprint, pprint_tree
|
|
7
6
|
from pyinaturalist.models import *
|
|
8
|
-
from pyinaturalist.paginator import Paginator, IDPaginator, WrapperPaginator
|
|
9
7
|
from pyinaturalist.request_params import get_interval_ranges
|
|
10
|
-
from pyinaturalist.session import ClientSession, FileLockSQLiteBucket, clear_cache
|
|
11
8
|
from pyinaturalist.v0 import *
|
|
12
9
|
from pyinaturalist.v2 import *
|
|
13
10
|
from pyinaturalist.v1 import *
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# ruff: noqa: F401, F403, F405
|
|
2
|
+
# isort: skip_file
|
|
3
|
+
from pyinaturalist.client.paginator import *
|
|
4
|
+
from pyinaturalist.client.session import *
|
|
5
|
+
from pyinaturalist.client.oauth import *
|
|
6
|
+
from pyinaturalist.client.oauth_callback import *
|
|
7
|
+
from pyinaturalist.client.client import iNatClient
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
'AutocompletePaginator',
|
|
11
|
+
'ClientSession',
|
|
12
|
+
'FileLockSQLiteBucket',
|
|
13
|
+
'IDPaginator',
|
|
14
|
+
'IDRangePaginator',
|
|
15
|
+
'JsonPaginator',
|
|
16
|
+
'Paginator',
|
|
17
|
+
'WrapperPaginator',
|
|
18
|
+
'build_authorize_url',
|
|
19
|
+
'clear_cache',
|
|
20
|
+
'delete',
|
|
21
|
+
'get',
|
|
22
|
+
'get_access_token',
|
|
23
|
+
'get_access_token_via_auth_code',
|
|
24
|
+
'get_auth_code_via_server',
|
|
25
|
+
'get_local_session',
|
|
26
|
+
'iNatClient',
|
|
27
|
+
'paginate_all',
|
|
28
|
+
'post',
|
|
29
|
+
'put',
|
|
30
|
+
'set_keyring_credentials',
|
|
31
|
+
]
|
{pyinaturalist-1.0.0.dev0/pyinaturalist → pyinaturalist-1.0.0.dev2/pyinaturalist/client}/client.py
RENAMED
|
@@ -3,30 +3,73 @@
|
|
|
3
3
|
# TODO: Use a custom template or directive to generate summary of all controller methods
|
|
4
4
|
from asyncio import AbstractEventLoop
|
|
5
5
|
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
6
8
|
from inspect import ismethod
|
|
7
9
|
from logging import getLogger
|
|
8
|
-
from typing import Any
|
|
10
|
+
from typing import Any, Literal
|
|
9
11
|
|
|
10
|
-
from
|
|
12
|
+
from requests import HTTPError
|
|
13
|
+
|
|
14
|
+
from pyinaturalist.client.oauth import (
|
|
15
|
+
_decode_jwt_exp,
|
|
16
|
+
get_access_token,
|
|
17
|
+
get_access_token_via_auth_code,
|
|
18
|
+
)
|
|
19
|
+
from pyinaturalist.client.paginator import Paginator
|
|
20
|
+
from pyinaturalist.client.session import ClientSession
|
|
11
21
|
from pyinaturalist.constants import RequestParams
|
|
12
22
|
from pyinaturalist.controllers import (
|
|
13
23
|
AnnotationController,
|
|
14
24
|
IdentificationController,
|
|
15
25
|
ObservationController,
|
|
26
|
+
ObservationFieldController,
|
|
16
27
|
PlaceController,
|
|
17
28
|
ProjectController,
|
|
18
29
|
SearchController,
|
|
19
30
|
TaxonController,
|
|
20
31
|
UserController,
|
|
21
32
|
)
|
|
33
|
+
from pyinaturalist.exceptions import AuthenticationError
|
|
22
34
|
from pyinaturalist.models import T
|
|
23
|
-
from pyinaturalist.paginator import Paginator
|
|
24
35
|
from pyinaturalist.request_params import get_valid_kwargs, strip_empty_values
|
|
25
|
-
|
|
36
|
+
|
|
37
|
+
JWT_EXPIRY_BUFFER = timedelta(seconds=60)
|
|
26
38
|
|
|
27
39
|
logger = getLogger(__name__)
|
|
28
40
|
|
|
29
41
|
|
|
42
|
+
@dataclass
|
|
43
|
+
class _TokenInfo:
|
|
44
|
+
"""Internal record of a fetched access token and its metadata."""
|
|
45
|
+
|
|
46
|
+
token: str
|
|
47
|
+
obtained_at: datetime # UTC datetime when the token was fetched
|
|
48
|
+
flow: Literal['password', 'authorization_code']
|
|
49
|
+
expires_at: datetime | None # decoded from JWT exp claim; None for non-JWT tokens
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
AUTH_CODE_CRED_KEYS = frozenset(
|
|
53
|
+
{
|
|
54
|
+
'app_id',
|
|
55
|
+
'app_secret',
|
|
56
|
+
'use_pkce',
|
|
57
|
+
'use_oob',
|
|
58
|
+
'refresh',
|
|
59
|
+
'port',
|
|
60
|
+
'timeout',
|
|
61
|
+
'open_url',
|
|
62
|
+
'get_code',
|
|
63
|
+
# Note: 'auth_flow' is intentionally excluded — it's a client-level key, not a
|
|
64
|
+
# parameter accepted by get_access_token_via_auth_code().
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
PASSWORD_FLOW_CRED_KEYS = frozenset({'username', 'password', 'app_id', 'app_secret', 'refresh'})
|
|
69
|
+
|
|
70
|
+
SUPPORTED_AUTH_FLOWS = frozenset({'password', 'authorization_code'})
|
|
71
|
+
|
|
72
|
+
|
|
30
73
|
# 'iNatClient' is nonstandard casing, but 'InatClient' just looks wrong. Deal with it, pep8.
|
|
31
74
|
class iNatClient:
|
|
32
75
|
"""API client class that provides an object-oriented interface to the iNaturalist API.
|
|
@@ -41,6 +84,7 @@ class iNatClient:
|
|
|
41
84
|
* :fa:`tag` :py:class:`annotations <.AnnotationController>`
|
|
42
85
|
* :fa:`fingerprint` :py:class:`identifications <.IdentificationController>`
|
|
43
86
|
* :fa:`binoculars` :py:class:`observations <.ObservationController>`
|
|
87
|
+
* :fa:`tag` :py:class:`observation_fields <.ObservationFieldController>`
|
|
44
88
|
* :fa:`location-dot` :py:class:`places <.PlaceController>`
|
|
45
89
|
* :fa:`users` :py:class:`projects <.ProjectController>`
|
|
46
90
|
* :fa:`search` :py:class:`search <.SearchController>`
|
|
@@ -48,8 +92,10 @@ class iNatClient:
|
|
|
48
92
|
* :fa:`user` :py:class:`users <.UserController>`
|
|
49
93
|
|
|
50
94
|
Args:
|
|
51
|
-
creds: Optional arguments for :py:func:`.get_access_token
|
|
52
|
-
|
|
95
|
+
creds: Optional arguments for :py:func:`.get_access_token` or
|
|
96
|
+
:py:func:`.get_access_token_via_auth_code`, used to get and refresh access
|
|
97
|
+
tokens as needed. Use ``auth_flow='authorization_code'`` to select authorization code
|
|
98
|
+
flow; otherwise password flow is used.
|
|
53
99
|
default_params: Default request parameters to pass to any applicable API requests
|
|
54
100
|
dry_run: Just log all requests instead of sending real requests
|
|
55
101
|
loop: An event loop to run any executors used for async iteration
|
|
@@ -59,7 +105,7 @@ class iNatClient:
|
|
|
59
105
|
|
|
60
106
|
def __init__(
|
|
61
107
|
self,
|
|
62
|
-
creds: dict[str,
|
|
108
|
+
creds: dict[str, Any] | None = None,
|
|
63
109
|
default_params: dict[str, Any] | None = None,
|
|
64
110
|
dry_run: bool = False,
|
|
65
111
|
loop: AbstractEventLoop | None = None,
|
|
@@ -67,13 +113,17 @@ class iNatClient:
|
|
|
67
113
|
**kwargs,
|
|
68
114
|
):
|
|
69
115
|
self.creds = creds or {}
|
|
116
|
+
auth_flow = self.creds.get('auth_flow')
|
|
117
|
+
if auth_flow is not None and auth_flow not in SUPPORTED_AUTH_FLOWS:
|
|
118
|
+
raise AuthenticationError(
|
|
119
|
+
f'Unsupported auth_flow {auth_flow!r}. '
|
|
120
|
+
f'Accepted values: {sorted(SUPPORTED_AUTH_FLOWS)}'
|
|
121
|
+
)
|
|
70
122
|
self.default_params = default_params or {}
|
|
71
123
|
self.dry_run = dry_run
|
|
72
124
|
self.loop = loop
|
|
73
125
|
self.session = session or ClientSession(**kwargs)
|
|
74
|
-
|
|
75
|
-
self._access_token = None
|
|
76
|
-
self._token_expires = None
|
|
126
|
+
self._token_info: _TokenInfo | None = None
|
|
77
127
|
|
|
78
128
|
# Controllers
|
|
79
129
|
self.annotations = AnnotationController(
|
|
@@ -85,6 +135,9 @@ class iNatClient:
|
|
|
85
135
|
self.observations = ObservationController(
|
|
86
136
|
self
|
|
87
137
|
) #: Interface for :py:class:`observation requests <.ObservationController>`
|
|
138
|
+
self.observation_fields = ObservationFieldController(
|
|
139
|
+
self
|
|
140
|
+
) #: Interface for :py:class:`observation field requests <.ObservationFieldController>`
|
|
88
141
|
self.places = PlaceController(
|
|
89
142
|
self
|
|
90
143
|
) #: Interface for :py:class:`place requests <.PlaceController>`
|
|
@@ -112,8 +165,9 @@ class iNatClient:
|
|
|
112
165
|
|
|
113
166
|
# Add access token if needed
|
|
114
167
|
if auth:
|
|
115
|
-
access_token =
|
|
116
|
-
|
|
168
|
+
access_token = self._resolve_access_token(kwargs)
|
|
169
|
+
if access_token:
|
|
170
|
+
client_kwargs['access_token'] = access_token
|
|
117
171
|
|
|
118
172
|
# Add default request parameters if applicable
|
|
119
173
|
client_kwargs.update(get_valid_kwargs(request_function, self.default_params))
|
|
@@ -126,6 +180,48 @@ class iNatClient:
|
|
|
126
180
|
|
|
127
181
|
return kwargs
|
|
128
182
|
|
|
183
|
+
def _resolve_access_token(self, kwargs: RequestParams) -> str | None:
|
|
184
|
+
"""Resolve an access token from request args, client cache, or credentials."""
|
|
185
|
+
access_token = kwargs.pop('access_token', None)
|
|
186
|
+
if access_token:
|
|
187
|
+
return access_token
|
|
188
|
+
if self._token_info:
|
|
189
|
+
still_valid = self._token_info.expires_at is None or self._token_info.expires_at > (
|
|
190
|
+
datetime.now(tz=timezone.utc) + JWT_EXPIRY_BUFFER
|
|
191
|
+
)
|
|
192
|
+
if still_valid:
|
|
193
|
+
return self._token_info.token
|
|
194
|
+
# Token is expired or about to expire — fall through to re-fetch
|
|
195
|
+
self._token_info = None
|
|
196
|
+
self._token_info = self._fetch_access_token_from_creds()
|
|
197
|
+
return self._token_info.token
|
|
198
|
+
|
|
199
|
+
def _fetch_access_token_from_creds(self, force_refresh: bool = False) -> _TokenInfo:
|
|
200
|
+
"""Get a new access token from configured credentials and wrap it in _TokenInfo."""
|
|
201
|
+
flow = self.creds.get('auth_flow', 'password')
|
|
202
|
+
if flow == 'authorization_code':
|
|
203
|
+
auth_code_creds = self._filter_creds(AUTH_CODE_CRED_KEYS, self.creds)
|
|
204
|
+
if force_refresh:
|
|
205
|
+
auth_code_creds['refresh'] = True
|
|
206
|
+
token = get_access_token_via_auth_code(**auth_code_creds)
|
|
207
|
+
else:
|
|
208
|
+
token_creds = self._filter_creds(PASSWORD_FLOW_CRED_KEYS, self.creds)
|
|
209
|
+
if force_refresh:
|
|
210
|
+
token_creds['refresh'] = True
|
|
211
|
+
token = get_access_token(**token_creds)
|
|
212
|
+
|
|
213
|
+
return _TokenInfo(
|
|
214
|
+
token=token,
|
|
215
|
+
obtained_at=datetime.now(tz=timezone.utc),
|
|
216
|
+
flow=flow,
|
|
217
|
+
expires_at=_decode_jwt_exp(token),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _filter_creds(valid_keys: frozenset, creds: dict[str, Any]) -> dict[str, Any]:
|
|
222
|
+
"""Keep only credentials accepted by the selected auth flow."""
|
|
223
|
+
return {k: v for k, v in creds.items() if k in valid_keys}
|
|
224
|
+
|
|
129
225
|
def paginate(
|
|
130
226
|
self,
|
|
131
227
|
request_function: Callable,
|
|
@@ -157,5 +253,19 @@ class iNatClient:
|
|
|
157
253
|
Returns:
|
|
158
254
|
Results of ``request_function()``
|
|
159
255
|
"""
|
|
256
|
+
explicit_access_token = kwargs.get('access_token') is not None
|
|
160
257
|
kwargs = self.add_defaults(request_function, kwargs, auth)
|
|
161
|
-
|
|
258
|
+
try:
|
|
259
|
+
return request_function(*args, **kwargs)
|
|
260
|
+
except HTTPError as e:
|
|
261
|
+
if not auth or explicit_access_token or not self._is_unauthorized_error(e):
|
|
262
|
+
raise
|
|
263
|
+
self._token_info = self._fetch_access_token_from_creds(force_refresh=True)
|
|
264
|
+
kwargs = kwargs.copy()
|
|
265
|
+
kwargs['access_token'] = self._token_info.token
|
|
266
|
+
return request_function(*args, **kwargs)
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
def _is_unauthorized_error(error: HTTPError) -> bool:
|
|
270
|
+
response = getattr(error, 'response', None)
|
|
271
|
+
return bool(response is not None and response.status_code == 401)
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import binascii
|
|
3
|
+
import json
|
|
4
|
+
import secrets
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from logging import getLogger
|
|
8
|
+
from os import getenv
|
|
9
|
+
|
|
10
|
+
from keyring import get_password, set_password
|
|
11
|
+
from keyring.errors import KeyringError
|
|
12
|
+
from requests import HTTPError, Response
|
|
13
|
+
|
|
14
|
+
from pyinaturalist.client.oauth_callback import (
|
|
15
|
+
_build_token_payload,
|
|
16
|
+
_generate_pkce_pair,
|
|
17
|
+
_obtain_auth_code,
|
|
18
|
+
_resolve_auth_code_creds,
|
|
19
|
+
build_authorize_url,
|
|
20
|
+
)
|
|
21
|
+
from pyinaturalist.client.session import ClientSession, get_local_session
|
|
22
|
+
from pyinaturalist.constants import API_V0, API_V1, KEYRING_KEY
|
|
23
|
+
from pyinaturalist.exceptions import AuthenticationError
|
|
24
|
+
|
|
25
|
+
_logger = getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _decode_jwt_exp(token: str) -> datetime | None:
|
|
29
|
+
"""Decode the exp claim from a JWT without verifying the signature.
|
|
30
|
+
|
|
31
|
+
Returns the expiry as a UTC datetime, or None if the token is not a
|
|
32
|
+
decodable JWT or has no exp claim.
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
parts = token.split('.')
|
|
36
|
+
if len(parts) != 3:
|
|
37
|
+
return None
|
|
38
|
+
# Add padding required by base64
|
|
39
|
+
payload_b64 = parts[1] + '=' * (-len(parts[1]) % 4)
|
|
40
|
+
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
|
|
41
|
+
exp = payload.get('exp')
|
|
42
|
+
return datetime.fromtimestamp(exp, tz=timezone.utc) if exp else None
|
|
43
|
+
except (
|
|
44
|
+
ValueError,
|
|
45
|
+
KeyError,
|
|
46
|
+
AttributeError,
|
|
47
|
+
OverflowError,
|
|
48
|
+
OSError,
|
|
49
|
+
TypeError,
|
|
50
|
+
binascii.Error,
|
|
51
|
+
):
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_access_token(
|
|
56
|
+
username: str | None = None,
|
|
57
|
+
password: str | None = None,
|
|
58
|
+
app_id: str | None = None,
|
|
59
|
+
app_secret: str | None = None,
|
|
60
|
+
jwt: bool = True,
|
|
61
|
+
refresh: bool = False,
|
|
62
|
+
) -> str:
|
|
63
|
+
"""Get an access token using the user's iNaturalist username and password, using the
|
|
64
|
+
Resource Owner Password Credentials Flow. Requires registering an iNaturalist app.
|
|
65
|
+
|
|
66
|
+
.. rubric:: Notes
|
|
67
|
+
|
|
68
|
+
* API reference: https://www.inaturalist.org/pages/api+reference#auth
|
|
69
|
+
* See :ref:`auth` for additional options for storing credentials.
|
|
70
|
+
* This can be used to get either a JWT or OAuth token. These can be used interchangeably for
|
|
71
|
+
many endpoints. JWT is preferred for newer endpoints.
|
|
72
|
+
|
|
73
|
+
Examples:
|
|
74
|
+
|
|
75
|
+
With direct keyword arguments:
|
|
76
|
+
|
|
77
|
+
>>> from pyinaturalist import get_access_token
|
|
78
|
+
>>> access_token = get_access_token(
|
|
79
|
+
>>> username='my_inaturalist_username',
|
|
80
|
+
>>> password='my_inaturalist_password',
|
|
81
|
+
>>> app_id='33f27dc63bdf27f4ca6cd95dd9dcd5df',
|
|
82
|
+
>>> app_secret='bbce628be722bfe2abd5fc566ba83de4',
|
|
83
|
+
>>> )
|
|
84
|
+
|
|
85
|
+
With environment variables or keyring configured:
|
|
86
|
+
|
|
87
|
+
>>> access_token = get_access_token()
|
|
88
|
+
|
|
89
|
+
If you would like to run custom requests for endpoints not yet implemented in pyinaturalist,
|
|
90
|
+
you can authenticate these requests by putting the token in your HTTP headers as follows:
|
|
91
|
+
|
|
92
|
+
>>> import requests
|
|
93
|
+
>>> requests.get(
|
|
94
|
+
>>> 'https://www.inaturalist.org/observations/1234',
|
|
95
|
+
>>> headers={'Authorization': f'Bearer {access_token}'},
|
|
96
|
+
>>> )
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
username: iNaturalist username (same as the one you use to login on inaturalist.org)
|
|
100
|
+
password: iNaturalist password (same as the one you use to login on inaturalist.org)
|
|
101
|
+
app_id: OAuth2 application ID
|
|
102
|
+
app_secret: OAuth2 application secret
|
|
103
|
+
jwt: Return a JSON Web Token; otherwise return an OAuth2 access token.
|
|
104
|
+
refresh: Do not use any cached tokens, even if they are not expired
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
:py:exc:`requests.HTTPError`: (401) if credentials are invalid
|
|
108
|
+
:py:exc:`.AuthenticationError`: if required credentials are missing
|
|
109
|
+
"""
|
|
110
|
+
session, cached = _get_cached_jwt(refresh)
|
|
111
|
+
if cached and jwt:
|
|
112
|
+
return cached
|
|
113
|
+
|
|
114
|
+
# Otherwise check for credentials in either args or environment variables
|
|
115
|
+
payload = {
|
|
116
|
+
'username': username or getenv('INAT_USERNAME'),
|
|
117
|
+
'password': password or getenv('INAT_PASSWORD'),
|
|
118
|
+
'client_id': app_id or getenv('INAT_APP_ID'),
|
|
119
|
+
'client_secret': app_secret or getenv('INAT_APP_SECRET'),
|
|
120
|
+
'grant_type': 'password',
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# If any fields were missing, then check the keyring
|
|
124
|
+
if not all(payload.values()):
|
|
125
|
+
payload |= {k: v for k, v in get_keyring_credentials().items() if not payload.get(k)}
|
|
126
|
+
if all(payload.values()):
|
|
127
|
+
_logger.info('Retrieved credentials from keyring')
|
|
128
|
+
else:
|
|
129
|
+
raise AuthenticationError('Not all authentication parameters were provided')
|
|
130
|
+
|
|
131
|
+
# Get OAuth access token
|
|
132
|
+
response = session.post(f'{API_V0}/oauth/token', json=payload)
|
|
133
|
+
access_token = response.json()['access_token']
|
|
134
|
+
|
|
135
|
+
# If specified, use OAuth token to get (and cache) a JWT
|
|
136
|
+
if jwt:
|
|
137
|
+
response = _get_jwt(session, access_token, refresh=refresh)
|
|
138
|
+
response.raise_for_status()
|
|
139
|
+
access_token = response.json()['api_token']
|
|
140
|
+
return access_token
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def get_access_token_via_auth_code(
|
|
144
|
+
app_id: str | None = None,
|
|
145
|
+
app_secret: str | None = None,
|
|
146
|
+
use_pkce: bool = True,
|
|
147
|
+
use_oob: bool = False,
|
|
148
|
+
jwt: bool = True,
|
|
149
|
+
refresh: bool = False,
|
|
150
|
+
port: int = 8080,
|
|
151
|
+
timeout: int = 120,
|
|
152
|
+
open_url: Callable[[str], None] | None = None,
|
|
153
|
+
get_code: Callable[[str], str] | None = None,
|
|
154
|
+
) -> str:
|
|
155
|
+
"""Get an access token using the OAuth2 Authorization Code flow, optionally with PKCE.
|
|
156
|
+
|
|
157
|
+
This is the recommended approach for CLI and desktop applications because users do not
|
|
158
|
+
need to provide their iNaturalist password to third-party code. Instead, the user
|
|
159
|
+
authenticates directly on inaturalist.org in their browser.
|
|
160
|
+
|
|
161
|
+
.. rubric:: Notes
|
|
162
|
+
|
|
163
|
+
* Requires registering an iNaturalist application at
|
|
164
|
+
https://www.inaturalist.org/oauth/applications
|
|
165
|
+
* When using PKCE (the default), no client secret is needed.
|
|
166
|
+
* When using the local callback server, register your redirect URI as
|
|
167
|
+
``http://127.0.0.1:<port>`` (e.g., ``http://127.0.0.1:8080``).
|
|
168
|
+
* When using OOB mode, register your redirect URI as
|
|
169
|
+
``urn:ietf:wg:oauth:2.0:oob``.
|
|
170
|
+
|
|
171
|
+
Examples:
|
|
172
|
+
|
|
173
|
+
With PKCE (recommended, no client secret needed):
|
|
174
|
+
|
|
175
|
+
>>> from pyinaturalist import get_access_token_via_auth_code
|
|
176
|
+
>>> access_token = get_access_token_via_auth_code(
|
|
177
|
+
... app_id='33f27dc63bdf27f4ca6cd95dd9dcd5df',
|
|
178
|
+
... )
|
|
179
|
+
|
|
180
|
+
Without PKCE (requires client secret):
|
|
181
|
+
|
|
182
|
+
>>> access_token = get_access_token_via_auth_code(
|
|
183
|
+
... app_id='33f27dc63bdf27f4ca6cd95dd9dcd5df',
|
|
184
|
+
... app_secret='bbce628be722bfe2abd5fc566ba83de4',
|
|
185
|
+
... use_pkce=False,
|
|
186
|
+
... )
|
|
187
|
+
|
|
188
|
+
For headless/remote environments (OOB mode):
|
|
189
|
+
|
|
190
|
+
>>> access_token = get_access_token_via_auth_code(
|
|
191
|
+
... app_id='33f27dc63bdf27f4ca6cd95dd9dcd5df',
|
|
192
|
+
... use_oob=True,
|
|
193
|
+
... )
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
app_id: OAuth2 application ID. Falls back to ``INAT_APP_ID`` env var or keyring.
|
|
197
|
+
app_secret: OAuth2 application secret. Required when ``use_pkce=False``.
|
|
198
|
+
use_pkce: Use PKCE (Proof Key for Code Exchange) instead of a client secret.
|
|
199
|
+
use_oob: Use out-of-band mode: the user manually copies the authorization code
|
|
200
|
+
instead of using a local callback server. Useful for headless environments.
|
|
201
|
+
jwt: Return a JSON Web Token; otherwise return an OAuth2 access token.
|
|
202
|
+
refresh: Do not use any cached tokens, even if they are not expired.
|
|
203
|
+
port: Port for the local callback server. Must match the redirect URI registered
|
|
204
|
+
with your iNaturalist application.
|
|
205
|
+
timeout: Seconds to wait for the user to complete authorization in the browser.
|
|
206
|
+
open_url: Optional callback to open the authorization URL. Defaults to
|
|
207
|
+
``webbrowser.open``. Useful for testing or custom browser handling.
|
|
208
|
+
get_code: Optional callback for OOB mode that receives the authorization URL and
|
|
209
|
+
returns the authorization code entered by the user. Defaults to ``input()``.
|
|
210
|
+
|
|
211
|
+
Raises:
|
|
212
|
+
:py:exc:`.AuthenticationError`: if credentials are missing, the user does not
|
|
213
|
+
authorize in time, or the token exchange fails.
|
|
214
|
+
"""
|
|
215
|
+
session, cached = _get_cached_jwt(refresh)
|
|
216
|
+
if cached and jwt:
|
|
217
|
+
return cached
|
|
218
|
+
|
|
219
|
+
app_id, app_secret = _resolve_auth_code_creds(app_id, app_secret, use_pkce)
|
|
220
|
+
|
|
221
|
+
# Generate PKCE pair if needed
|
|
222
|
+
code_verifier: str | None = None
|
|
223
|
+
code_challenge: str | None = None
|
|
224
|
+
if use_pkce:
|
|
225
|
+
code_verifier, code_challenge = _generate_pkce_pair()
|
|
226
|
+
|
|
227
|
+
# Build authorization URL and get the authorization code
|
|
228
|
+
redirect_uri = 'urn:ietf:wg:oauth:2.0:oob' if use_oob else f'http://127.0.0.1:{port}'
|
|
229
|
+
state = secrets.token_urlsafe(32) if not use_oob else None
|
|
230
|
+
authorize_url = build_authorize_url(app_id, redirect_uri, code_challenge, state)
|
|
231
|
+
auth_code = _obtain_auth_code(
|
|
232
|
+
authorize_url,
|
|
233
|
+
use_oob=use_oob,
|
|
234
|
+
port=port,
|
|
235
|
+
timeout=timeout,
|
|
236
|
+
state=state,
|
|
237
|
+
open_url=open_url,
|
|
238
|
+
get_code=get_code,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# Exchange authorization code for access token
|
|
242
|
+
payload = _build_token_payload(
|
|
243
|
+
app_id, auth_code, redirect_uri, code_verifier, app_secret, use_pkce
|
|
244
|
+
)
|
|
245
|
+
response = session.post(f'{API_V0}/oauth/token', json=payload)
|
|
246
|
+
access_token = response.json()['access_token']
|
|
247
|
+
|
|
248
|
+
# If specified, use OAuth token to get (and cache) a JWT
|
|
249
|
+
if jwt:
|
|
250
|
+
response = _get_jwt(session, access_token, refresh=refresh)
|
|
251
|
+
response.raise_for_status()
|
|
252
|
+
access_token = response.json()['api_token']
|
|
253
|
+
return access_token
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _get_cached_jwt(refresh: bool) -> tuple[ClientSession, str | None]:
|
|
257
|
+
"""Return (session, token) if a valid cached JWT exists"""
|
|
258
|
+
session = get_local_session()
|
|
259
|
+
response = _get_jwt(session, only_if_cached=True)
|
|
260
|
+
if response.ok and not refresh:
|
|
261
|
+
_logger.info('Using cached access token')
|
|
262
|
+
return session, response.json()['api_token']
|
|
263
|
+
return session, None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def validate_token(access_token: str) -> bool:
|
|
267
|
+
"""Determine if an access token is valid.
|
|
268
|
+
|
|
269
|
+
Returns ``False`` when the server confirms the token is invalid (401);
|
|
270
|
+
other failures are not re-raised.
|
|
271
|
+
|
|
272
|
+
Raises:
|
|
273
|
+
:py:exc:`requests.HTTPError`: for any non-401 error response
|
|
274
|
+
"""
|
|
275
|
+
session = get_local_session()
|
|
276
|
+
try:
|
|
277
|
+
session.request('GET', f'{API_V1}/users/me', access_token=access_token)
|
|
278
|
+
return True
|
|
279
|
+
except HTTPError as e:
|
|
280
|
+
if e.response is not None and e.response.status_code == 401:
|
|
281
|
+
return False
|
|
282
|
+
raise
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def get_keyring_credentials() -> dict[str, str | None]:
|
|
286
|
+
"""Attempt to get iNaturalist credentials from the system keyring
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
OAuth-compatible credentials dict
|
|
290
|
+
"""
|
|
291
|
+
try:
|
|
292
|
+
return {
|
|
293
|
+
'username': get_password(KEYRING_KEY, 'username'),
|
|
294
|
+
'password': get_password(KEYRING_KEY, 'password'),
|
|
295
|
+
'client_id': get_password(KEYRING_KEY, 'app_id'),
|
|
296
|
+
'client_secret': get_password(KEYRING_KEY, 'app_secret'),
|
|
297
|
+
}
|
|
298
|
+
except KeyringError as e:
|
|
299
|
+
_logger.warning(e)
|
|
300
|
+
return {}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def set_keyring_credentials(
|
|
304
|
+
username: str,
|
|
305
|
+
password: str,
|
|
306
|
+
app_id: str,
|
|
307
|
+
app_secret: str,
|
|
308
|
+
):
|
|
309
|
+
"""
|
|
310
|
+
Store iNaturalist credentials in the system keyring for future use.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
username: iNaturalist username
|
|
314
|
+
password: iNaturalist password
|
|
315
|
+
app_id: iNaturalist application ID
|
|
316
|
+
app_secret: iNaturalist application secret
|
|
317
|
+
"""
|
|
318
|
+
set_password(KEYRING_KEY, 'username', username)
|
|
319
|
+
set_password(KEYRING_KEY, 'password', password)
|
|
320
|
+
set_password(KEYRING_KEY, 'app_id', app_id)
|
|
321
|
+
set_password(KEYRING_KEY, 'app_secret', app_secret)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _get_jwt(
|
|
325
|
+
session: ClientSession,
|
|
326
|
+
access_token: str | None = None,
|
|
327
|
+
only_if_cached: bool = False,
|
|
328
|
+
refresh: bool = False,
|
|
329
|
+
) -> Response:
|
|
330
|
+
headers = {'Authorization': f'Bearer {access_token}'} if access_token else {}
|
|
331
|
+
return session.get(
|
|
332
|
+
f'{API_V0}/users/api_token',
|
|
333
|
+
headers=headers,
|
|
334
|
+
only_if_cached=only_if_cached, # If True, will return a 504 if not cached
|
|
335
|
+
force_refresh=refresh,
|
|
336
|
+
raise_for_status=False, # type: ignore
|
|
337
|
+
)
|