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.
Files changed (73) hide show
  1. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/PKG-INFO +2 -2
  2. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/__init__.py +1 -4
  3. pyinaturalist-1.0.0.dev2/pyinaturalist/client/__init__.py +31 -0
  4. {pyinaturalist-1.0.0.dev0/pyinaturalist → pyinaturalist-1.0.0.dev2/pyinaturalist/client}/client.py +123 -13
  5. pyinaturalist-1.0.0.dev2/pyinaturalist/client/oauth.py +337 -0
  6. pyinaturalist-1.0.0.dev2/pyinaturalist/client/oauth_callback.py +278 -0
  7. {pyinaturalist-1.0.0.dev0/pyinaturalist → pyinaturalist-1.0.0.dev2/pyinaturalist/client}/paginator.py +4 -4
  8. {pyinaturalist-1.0.0.dev0/pyinaturalist → pyinaturalist-1.0.0.dev2/pyinaturalist/client}/session.py +121 -23
  9. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/constants.py +80 -17
  10. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/__init__.py +1 -0
  11. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/annotation_controller.py +52 -5
  12. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/base_controller.py +1 -1
  13. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/identification_controller.py +8 -6
  14. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/observation_controller.py +14 -9
  15. pyinaturalist-1.0.0.dev2/pyinaturalist/controllers/observation_field_controller.py +103 -0
  16. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/place_controller.py +4 -1
  17. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/project_controller.py +7 -1
  18. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/search_controller.py +1 -1
  19. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/taxon_controller.py +65 -6
  20. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/user_controller.py +7 -1
  21. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/docs/templates.py +34 -19
  22. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/formatters.py +4 -89
  23. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/controlled_term.py +14 -0
  24. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/observation.py +12 -3
  25. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/taxon.py +1 -0
  26. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v0/observation_fields.py +1 -1
  27. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v0/observations.py +1 -1
  28. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/__init__.py +0 -1
  29. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/controlled_terms.py +1 -1
  30. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/identifications.py +1 -2
  31. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/messages.py +1 -1
  32. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/observation_fields.py +1 -1
  33. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/observations.py +2 -39
  34. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/places.py +1 -2
  35. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/posts.py +1 -1
  36. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/projects.py +9 -2
  37. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/search.py +1 -1
  38. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/taxa.py +1 -2
  39. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/users.py +1 -1
  40. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v2/observations.py +1 -2
  41. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v2/taxa.py +1 -2
  42. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyproject.toml +17 -17
  43. pyinaturalist-1.0.0.dev0/pyinaturalist/auth.py +0 -165
  44. pyinaturalist-1.0.0.dev0/pyinaturalist/node_api.py +0 -22
  45. pyinaturalist-1.0.0.dev0/pyinaturalist/rest_api.py +0 -25
  46. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/.gitignore +0 -0
  47. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/LICENSE +0 -0
  48. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/README.md +0 -0
  49. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinat/__init__.py +0 -0
  50. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/converters.py +0 -0
  51. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/docs/__init__.py +0 -0
  52. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/docs/docstrings.py +0 -0
  53. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/docs/emoji.py +0 -0
  54. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/docs/model_docs.py +0 -0
  55. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/docs/signatures.py +0 -0
  56. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/exceptions.py +0 -0
  57. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/__init__.py +0 -0
  58. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/base.py +0 -0
  59. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/checklist.py +0 -0
  60. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/conservation_status.py +0 -0
  61. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/identification.py +0 -0
  62. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/lazy_property.py +0 -0
  63. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/media.py +0 -0
  64. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/message.py +0 -0
  65. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/observation_field.py +0 -0
  66. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/place.py +0 -0
  67. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/project.py +0 -0
  68. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/search.py +0 -0
  69. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/user.py +0 -0
  70. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/py.typed +0 -0
  71. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/request_params.py +0 -0
  72. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v0/__init__.py +0 -0
  73. {pyinaturalist-1.0.0.dev0 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v2/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: pyinaturalist
3
- Version: 1.0.0.dev0
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.auth import get_access_token
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
+ ]
@@ -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 pyinaturalist.auth import get_access_token
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
- from pyinaturalist.session import ClientSession
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`, used to get and refresh access
52
- tokens as needed. Using a keyring instead is recommended, though.
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, str] | None = None,
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 = kwargs.pop('access_token', None) or get_access_token(**self.creds) # type: ignore
116
- client_kwargs['access_token'] = access_token
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
- return request_function(*args, **kwargs)
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
+ )