pyinaturalist 1.0.0.dev1__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 (70) hide show
  1. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/PKG-INFO +2 -2
  2. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/client/oauth.py +32 -12
  3. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/client/session.py +5 -4
  4. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/constants.py +11 -1
  5. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/search_controller.py +1 -1
  6. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyproject.toml +2 -2
  7. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/.gitignore +0 -0
  8. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/LICENSE +0 -0
  9. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/README.md +0 -0
  10. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinat/__init__.py +0 -0
  11. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/__init__.py +0 -0
  12. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/client/__init__.py +0 -0
  13. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/client/client.py +0 -0
  14. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/client/oauth_callback.py +0 -0
  15. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/client/paginator.py +0 -0
  16. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/__init__.py +0 -0
  17. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/annotation_controller.py +0 -0
  18. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/base_controller.py +0 -0
  19. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/identification_controller.py +0 -0
  20. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/observation_controller.py +0 -0
  21. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/observation_field_controller.py +0 -0
  22. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/place_controller.py +0 -0
  23. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/project_controller.py +0 -0
  24. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/taxon_controller.py +0 -0
  25. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/controllers/user_controller.py +0 -0
  26. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/converters.py +0 -0
  27. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/docs/__init__.py +0 -0
  28. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/docs/docstrings.py +0 -0
  29. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/docs/emoji.py +0 -0
  30. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/docs/model_docs.py +0 -0
  31. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/docs/signatures.py +0 -0
  32. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/docs/templates.py +0 -0
  33. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/exceptions.py +0 -0
  34. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/formatters.py +0 -0
  35. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/__init__.py +0 -0
  36. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/base.py +0 -0
  37. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/checklist.py +0 -0
  38. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/conservation_status.py +0 -0
  39. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/controlled_term.py +0 -0
  40. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/identification.py +0 -0
  41. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/lazy_property.py +0 -0
  42. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/media.py +0 -0
  43. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/message.py +0 -0
  44. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/observation.py +0 -0
  45. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/observation_field.py +0 -0
  46. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/place.py +0 -0
  47. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/project.py +0 -0
  48. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/search.py +0 -0
  49. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/taxon.py +0 -0
  50. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/models/user.py +0 -0
  51. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/py.typed +0 -0
  52. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/request_params.py +0 -0
  53. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v0/__init__.py +0 -0
  54. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v0/observation_fields.py +0 -0
  55. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v0/observations.py +0 -0
  56. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/__init__.py +0 -0
  57. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/controlled_terms.py +0 -0
  58. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/identifications.py +0 -0
  59. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/messages.py +0 -0
  60. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/observation_fields.py +0 -0
  61. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/observations.py +0 -0
  62. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/places.py +0 -0
  63. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/posts.py +0 -0
  64. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/projects.py +0 -0
  65. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/search.py +0 -0
  66. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/taxa.py +0 -0
  67. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v1/users.py +0 -0
  68. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v2/__init__.py +0 -0
  69. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v2/observations.py +0 -0
  70. {pyinaturalist-1.0.0.dev1 → pyinaturalist-1.0.0.dev2}/pyinaturalist/v2/taxa.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.dev1
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
@@ -40,7 +40,15 @@ def _decode_jwt_exp(token: str) -> datetime | None:
40
40
  payload = json.loads(base64.urlsafe_b64decode(payload_b64))
41
41
  exp = payload.get('exp')
42
42
  return datetime.fromtimestamp(exp, tz=timezone.utc) if exp else None
43
- except (ValueError, KeyError, AttributeError, OverflowError, binascii.Error):
43
+ except (
44
+ ValueError,
45
+ KeyError,
46
+ AttributeError,
47
+ OverflowError,
48
+ OSError,
49
+ TypeError,
50
+ binascii.Error,
51
+ ):
44
52
  return None
45
53
 
46
54
 
@@ -97,9 +105,10 @@ def get_access_token(
97
105
 
98
106
  Raises:
99
107
  :py:exc:`requests.HTTPError`: (401) if credentials are invalid
108
+ :py:exc:`.AuthenticationError`: if required credentials are missing
100
109
  """
101
110
  session, cached = _get_cached_jwt(refresh)
102
- if cached:
111
+ if cached and jwt:
103
112
  return cached
104
113
 
105
114
  # Otherwise check for credentials in either args or environment variables
@@ -113,7 +122,7 @@ def get_access_token(
113
122
 
114
123
  # If any fields were missing, then check the keyring
115
124
  if not all(payload.values()):
116
- payload.update(get_keyring_credentials())
125
+ payload |= {k: v for k, v in get_keyring_credentials().items() if not payload.get(k)}
117
126
  if all(payload.values()):
118
127
  _logger.info('Retrieved credentials from keyring')
119
128
  else:
@@ -121,12 +130,11 @@ def get_access_token(
121
130
 
122
131
  # Get OAuth access token
123
132
  response = session.post(f'{API_V0}/oauth/token', json=payload)
124
- response.raise_for_status()
125
133
  access_token = response.json()['access_token']
126
134
 
127
135
  # If specified, use OAuth token to get (and cache) a JWT
128
136
  if jwt:
129
- response = _get_jwt(session, access_token)
137
+ response = _get_jwt(session, access_token, refresh=refresh)
130
138
  response.raise_for_status()
131
139
  access_token = response.json()['api_token']
132
140
  return access_token
@@ -205,7 +213,7 @@ def get_access_token_via_auth_code(
205
213
  authorize in time, or the token exchange fails.
206
214
  """
207
215
  session, cached = _get_cached_jwt(refresh)
208
- if cached:
216
+ if cached and jwt:
209
217
  return cached
210
218
 
211
219
  app_id, app_secret = _resolve_auth_code_creds(app_id, app_secret, use_pkce)
@@ -235,12 +243,11 @@ def get_access_token_via_auth_code(
235
243
  app_id, auth_code, redirect_uri, code_verifier, app_secret, use_pkce
236
244
  )
237
245
  response = session.post(f'{API_V0}/oauth/token', json=payload)
238
- response.raise_for_status()
239
246
  access_token = response.json()['access_token']
240
247
 
241
248
  # If specified, use OAuth token to get (and cache) a JWT
242
249
  if jwt:
243
- response = _get_jwt(session, access_token)
250
+ response = _get_jwt(session, access_token, refresh=refresh)
244
251
  response.raise_for_status()
245
252
  access_token = response.json()['api_token']
246
253
  return access_token
@@ -257,13 +264,22 @@ def _get_cached_jwt(refresh: bool) -> tuple[ClientSession, str | None]:
257
264
 
258
265
 
259
266
  def validate_token(access_token: str) -> bool:
260
- """Determine if an access token is valid"""
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
+ """
261
275
  session = get_local_session()
262
276
  try:
263
277
  session.request('GET', f'{API_V1}/users/me', access_token=access_token)
264
278
  return True
265
- except HTTPError:
266
- return False
279
+ except HTTPError as e:
280
+ if e.response is not None and e.response.status_code == 401:
281
+ return False
282
+ raise
267
283
 
268
284
 
269
285
  def get_keyring_credentials() -> dict[str, str | None]:
@@ -306,12 +322,16 @@ def set_keyring_credentials(
306
322
 
307
323
 
308
324
  def _get_jwt(
309
- session: ClientSession, access_token: str | None = None, only_if_cached: bool = False
325
+ session: ClientSession,
326
+ access_token: str | None = None,
327
+ only_if_cached: bool = False,
328
+ refresh: bool = False,
310
329
  ) -> Response:
311
330
  headers = {'Authorization': f'Bearer {access_token}'} if access_token else {}
312
331
  return session.get(
313
332
  f'{API_V0}/users/api_token',
314
333
  headers=headers,
315
334
  only_if_cached=only_if_cached, # If True, will return a 504 if not cached
335
+ force_refresh=refresh,
316
336
  raise_for_status=False, # type: ignore
317
337
  )
@@ -49,6 +49,7 @@ from pyinaturalist.constants import (
49
49
  REQUESTS_PER_SECOND,
50
50
  RETRY_BACKOFF,
51
51
  RETRY_STATUSES,
52
+ RETRYABLE_CONNECTION_ERRORS,
52
53
  WRITE_HTTP_METHODS,
53
54
  WRITE_TIMEOUT,
54
55
  FileOrPath,
@@ -356,12 +357,12 @@ class ClientSession(CacheMixin, LimiterMixin, Session):
356
357
  timeout=timeout, # type: ignore[arg-type]
357
358
  **kwargs,
358
359
  )
359
- # Handle write timeouts, which are not captured by urllib3 retry handling;
360
+ # Handle connection errors not captured by urllib3 retry handling (write timeouts, remote disconnects);
360
361
  except ConnectionError as e:
361
- if 'write operation timed out' not in str(e):
362
+ if not any(msg in str(e).lower() for msg in RETRYABLE_CONNECTION_ERRORS):
362
363
  raise
363
- _logger.debug('Write timed out:', exc_info=True)
364
- _logger.warning('Write timed out; retrying...')
364
+ _logger.debug('Connection error:', exc_info=True)
365
+ _logger.warning('Connection error; retrying...')
365
366
 
366
367
  # Reuse the same retry object to share retry state and limits
367
368
  retries = retries or self.retries
@@ -42,6 +42,10 @@ REQUEST_TIMEOUT = 20
42
42
  REQUEST_RETRIES = 5 # Maximum number of retries for a failed request
43
43
  RETRY_BACKOFF = 0.5 # Exponential backoff factor for retries
44
44
  RETRY_STATUSES = (500, 502, 503, 504)
45
+ RETRYABLE_CONNECTION_ERRORS = (
46
+ 'write operation timed out',
47
+ 'remote end closed connection without response',
48
+ )
45
49
  WRITE_TIMEOUT = 60
46
50
 
47
51
  # HTTP methods that apply to write-only dry-run mode
@@ -152,6 +156,13 @@ RANK_LEVELS = {
152
156
  }
153
157
  RANKS = list(RANK_LEVELS.keys())[:-2]
154
158
 
159
+ # When there are multiple ranks for a given level, this maps to the most representative/common one
160
+ RANK_LEVEL_REPRESENTATIVE_RANKS = {
161
+ 5: 'subspecies',
162
+ 10: 'species',
163
+ 20: 'genus',
164
+ }
165
+
155
166
  # The main 7 taxonomic ranks, used for condensed display
156
167
  COMMON_RANKS = [
157
168
  'species',
@@ -176,7 +187,6 @@ RANK_EQUIVALENTS = {
176
187
  'var': 'variety',
177
188
  }
178
189
 
179
-
180
190
  # Type aliases
181
191
  # Multiple-choice parameter type aliases
182
192
  Inbox = Literal['inbox', 'sent', 'any']
@@ -1,7 +1,7 @@
1
1
  from pyinaturalist.constants import MultiInt, MultiStr
2
2
  from pyinaturalist.controllers import BaseController
3
3
  from pyinaturalist.models import SearchResult
4
- from pyinaturalist.v1 import search
4
+ from pyinaturalist.v1.search import search
5
5
 
6
6
 
7
7
  class SearchController(BaseController):
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = 'pyinaturalist'
3
- version = "1.0.0.dev1"
3
+ version = "1.0.0.dev2"
4
4
  description = 'iNaturalist API client for python'
5
5
  authors = [{ name = 'Jordan Cook' }]
6
6
  license = 'MIT'
@@ -136,7 +136,7 @@ fix = true
136
136
  unsafe-fixes = true
137
137
  line-length = 100
138
138
  output-format = 'grouped'
139
- exclude = ['examples/', 'scripts/specs/', 'test/sample_data/']
139
+ exclude = ['README.md', 'examples/', 'scripts/specs/', 'test/sample_data/']
140
140
 
141
141
  [tool.ruff.format]
142
142
  quote-style = 'single'