ebird-api-requests 4.1.1__tar.gz → 4.3.0__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 (22) hide show
  1. {ebird_api_requests-4.1.1/src/ebird_api_requests.egg-info → ebird_api_requests-4.3.0}/PKG-INFO +25 -1
  2. {ebird_api_requests-4.1.1 → ebird_api_requests-4.3.0}/README.md +24 -0
  3. {ebird_api_requests-4.1.1 → ebird_api_requests-4.3.0}/pyproject.toml +2 -2
  4. {ebird_api_requests-4.1.1 → ebird_api_requests-4.3.0}/src/ebird/api/requests/__init__.py +6 -1
  5. {ebird_api_requests-4.1.1 → ebird_api_requests-4.3.0}/src/ebird/api/requests/client.py +5 -1
  6. {ebird_api_requests-4.1.1 → ebird_api_requests-4.3.0}/src/ebird/api/requests/constants.py +3 -0
  7. {ebird_api_requests-4.1.1 → ebird_api_requests-4.3.0}/src/ebird/api/requests/observations.py +6 -1
  8. {ebird_api_requests-4.1.1 → ebird_api_requests-4.3.0}/src/ebird/api/requests/statistics.py +2 -2
  9. {ebird_api_requests-4.1.1 → ebird_api_requests-4.3.0}/src/ebird/api/requests/utils.py +123 -4
  10. {ebird_api_requests-4.1.1 → ebird_api_requests-4.3.0}/src/ebird/api/requests/validation.py +24 -2
  11. {ebird_api_requests-4.1.1 → ebird_api_requests-4.3.0/src/ebird_api_requests.egg-info}/PKG-INFO +25 -1
  12. {ebird_api_requests-4.1.1 → ebird_api_requests-4.3.0}/LICENSE.txt +0 -0
  13. {ebird_api_requests-4.1.1 → ebird_api_requests-4.3.0}/setup.cfg +0 -0
  14. {ebird_api_requests-4.1.1 → ebird_api_requests-4.3.0}/setup.py +0 -0
  15. {ebird_api_requests-4.1.1 → ebird_api_requests-4.3.0}/src/ebird/api/requests/checklists.py +0 -0
  16. {ebird_api_requests-4.1.1 → ebird_api_requests-4.3.0}/src/ebird/api/requests/hotspots.py +0 -0
  17. {ebird_api_requests-4.1.1 → ebird_api_requests-4.3.0}/src/ebird/api/requests/regions.py +0 -0
  18. {ebird_api_requests-4.1.1 → ebird_api_requests-4.3.0}/src/ebird/api/requests/species.py +0 -0
  19. {ebird_api_requests-4.1.1 → ebird_api_requests-4.3.0}/src/ebird/api/requests/taxonomy.py +0 -0
  20. {ebird_api_requests-4.1.1 → ebird_api_requests-4.3.0}/src/ebird_api_requests.egg-info/SOURCES.txt +0 -0
  21. {ebird_api_requests-4.1.1 → ebird_api_requests-4.3.0}/src/ebird_api_requests.egg-info/dependency_links.txt +0 -0
  22. {ebird_api_requests-4.1.1 → ebird_api_requests-4.3.0}/src/ebird_api_requests.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ebird-api-requests
3
- Version: 4.1.1
3
+ Version: 4.3.0
4
4
  Summary: A python library for accessing the eBird API, version 2.0
5
5
  Author-email: Stuart MacKay <smackay@flagstonesoftware.com>
6
6
  License: MIT License
@@ -346,6 +346,30 @@ client.get_observations('MX-OAX')
346
346
 
347
347
  The client supports all the API functions.
348
348
 
349
+ ### Rate Limiting
350
+
351
+ The eBird API limits how quickly requests can be made. If you exceed the
352
+ limit the API responds with HTTP 429 Too Many Requests. If you are making
353
+ lots of calls, for example downloading all the checklists for a region,
354
+ then set a minimum interval, in seconds, between requests:
355
+
356
+ ```python
357
+ from ebird.api.requests import set_rate_limit
358
+
359
+ set_rate_limit(1.0)
360
+ ```
361
+
362
+ The limit applies to all the requests made by the process. It is disabled
363
+ by default. If the API still responds with HTTP 429, the interval is doubled,
364
+ up to a maximum of 60 seconds, so the requests slow down to a rate the API
365
+ accepts. Use get_rate_limit() to see the current interval.
366
+
367
+ When an HTTP 429 response is returned, the request is retried after waiting
368
+ for the time given in the Retry-After header. If the request still fails
369
+ after all the retries are used, a RateLimitError is raised. It is a subclass
370
+ of HTTPError, and the retry_after attribute contains the number of seconds
371
+ the API asked you to wait, or None if no Retry-After header was returned.
372
+
349
373
  ## Formats
350
374
 
351
375
  Most of the eBird API calls return JSON. Some of the calls such as getting
@@ -316,6 +316,30 @@ client.get_observations('MX-OAX')
316
316
 
317
317
  The client supports all the API functions.
318
318
 
319
+ ### Rate Limiting
320
+
321
+ The eBird API limits how quickly requests can be made. If you exceed the
322
+ limit the API responds with HTTP 429 Too Many Requests. If you are making
323
+ lots of calls, for example downloading all the checklists for a region,
324
+ then set a minimum interval, in seconds, between requests:
325
+
326
+ ```python
327
+ from ebird.api.requests import set_rate_limit
328
+
329
+ set_rate_limit(1.0)
330
+ ```
331
+
332
+ The limit applies to all the requests made by the process. It is disabled
333
+ by default. If the API still responds with HTTP 429, the interval is doubled,
334
+ up to a maximum of 60 seconds, so the requests slow down to a rate the API
335
+ accepts. Use get_rate_limit() to see the current interval.
336
+
337
+ When an HTTP 429 response is returned, the request is retried after waiting
338
+ for the time given in the Retry-After header. If the request still fails
339
+ after all the retries are used, a RateLimitError is raised. It is a subclass
340
+ of HTTPError, and the retry_after attribute contains the number of seconds
341
+ the API asked you to wait, or None if no Retry-After header was returned.
342
+
319
343
  ## Formats
320
344
 
321
345
  Most of the eBird API calls return JSON. Some of the calls such as getting
@@ -29,7 +29,7 @@ license = {text = "MIT License"}
29
29
  name = "ebird-api-requests"
30
30
  readme = "README.md"
31
31
  requires-python = ">= 3.10"
32
- version = "4.1.1"
32
+ version = "4.3.0"
33
33
 
34
34
  [project.urls]
35
35
  Repository = "https://github.com/StuartMacKay/ebird-api-requests.git"
@@ -37,7 +37,7 @@ Issues = "https://github.com/StuartMacKay/ebird-api-requests/issues"
37
37
  Changelog = "https://github.com/StuartMacKay/ebird-api-requests/blob/master/CHANGELOG.md"
38
38
 
39
39
  [tool.bumpversion]
40
- current_version = "4.1.1"
40
+ current_version = "4.3.0"
41
41
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
42
42
  serialize = ["{major}.{minor}.{patch}"]
43
43
  ignore_missing_version = false
@@ -2,7 +2,7 @@
2
2
 
3
3
  """A set of wrapper functions for accessing the eBird API."""
4
4
 
5
- __version__ = "4.1.1"
5
+ __version__ = "4.3.0"
6
6
 
7
7
  from ebird.api.requests.checklists import get_checklist, get_visits
8
8
  from ebird.api.requests.client import Client
@@ -37,3 +37,8 @@ from ebird.api.requests.taxonomy import (
37
37
  get_taxonomy_locales,
38
38
  get_taxonomy_versions,
39
39
  )
40
+ from ebird.api.requests.utils import (
41
+ RateLimitError,
42
+ get_rate_limit,
43
+ set_rate_limit,
44
+ )
@@ -109,7 +109,7 @@ class Client:
109
109
  category=self.category,
110
110
  )
111
111
 
112
- def get_historic_observations(self, area, date):
112
+ def get_historic_observations(self, area, date, rank="mrec"):
113
113
  """Get recent observations for a region.
114
114
 
115
115
  :param area: a country, subnational1, subnational2 or location code
@@ -117,6 +117,9 @@ class Client:
117
117
 
118
118
  :param date: the date, since Jan 1st 1800.
119
119
 
120
+ :param rank: for each species, return the latest observation on the
121
+ date, 'mrec' (the default), or the first observation, 'create'.
122
+
120
123
  :return: the list of observations in simple format.
121
124
 
122
125
  """
@@ -130,6 +133,7 @@ class Client:
130
133
  hotspot=self.hotspot,
131
134
  detail=self.detail,
132
135
  category=self.category,
136
+ rank=rank,
133
137
  )
134
138
 
135
139
  def get_nearby_observations(self, lat, lng, dist=25):
@@ -7,6 +7,7 @@ DEFAULT_HOTSPOTS_ONLY = "false"
7
7
  DEFAULT_PROVISIONAL = "false"
8
8
  DEFAULT_LOCALE = "en"
9
9
  DEFAULT_OBSERVATION_ORDER = "date"
10
+ DEFAULT_OBSERVATION_RANK = "mrec"
10
11
  DEFAULT_SPECIES_CATEGORY = None
11
12
  DEFAULT_TAXONOMY_ORDER = "ebird"
12
13
  DEFAULT_MAX_OBSERVATIONS = None
@@ -102,6 +103,8 @@ REGION_TYPES = ["country", "subnational1", "subnational2"]
102
103
 
103
104
  TOP_100_RANK = ["spp", "cl"]
104
105
 
106
+ OBSERVATION_RANK = ["mrec", "create"]
107
+
105
108
  # Just occasionally the connection to eBird freezes. The timeout
106
109
  # will raise an error which is preferable to the sitting there
107
110
  # waiting for something to happen, when it will not.
@@ -14,6 +14,7 @@ from ebird.api.requests.validation import (
14
14
  clean_lng,
15
15
  clean_locale,
16
16
  clean_max_observations,
17
+ clean_observation_rank,
17
18
  clean_provisional,
18
19
  clean_sort,
19
20
  )
@@ -633,6 +634,7 @@ def get_historic_observations(
633
634
  hotspot=False,
634
635
  detail="simple",
635
636
  category=None,
637
+ rank="mrec",
636
638
  ):
637
639
  """Get recent observations for a region.
638
640
 
@@ -671,6 +673,9 @@ def get_historic_observations(
671
673
  More than one value can be given in a comma-separated string. The default
672
674
  value is None and records from all categories will be returned.
673
675
 
676
+ :param rank: for each species, return the latest observation on the
677
+ date, 'mrec' (the default), or the first observation, 'create'.
678
+
674
679
  :return: the list of observations in simple format.
675
680
 
676
681
  :raises ValueError: if any of the arguments fail the validation checks.
@@ -686,7 +691,7 @@ def get_historic_observations(
686
691
  url = HISTORIC_OBSERVATIONS_URL % (cleaned[0], date.strftime("%Y/%m/%d"))
687
692
 
688
693
  params = {
689
- "rank": "mrec",
694
+ "rank": clean_observation_rank(rank),
690
695
  "detail": clean_detail(detail),
691
696
  "sppLocale": clean_locale(locale),
692
697
  "includeProvisional": clean_provisional(provisional),
@@ -5,7 +5,7 @@ from ebird.api.requests.validation import (
5
5
  clean_area,
6
6
  clean_date,
7
7
  clean_max_observers,
8
- clean_rank,
8
+ clean_observer_rank,
9
9
  clean_region,
10
10
  )
11
11
 
@@ -46,7 +46,7 @@ def get_top_100(token, region, date, rank="spp", max_results=100):
46
46
 
47
47
  params = {
48
48
  "maxObservers": clean_max_observers(max_results),
49
- "rankedBy": clean_rank(rank),
49
+ "rankedBy": clean_observer_rank(rank),
50
50
  }
51
51
 
52
52
  headers = {
@@ -1,13 +1,122 @@
1
1
  """Various functions used in the API."""
2
2
 
3
+ import datetime as dt
3
4
  import json
5
+ import threading
4
6
  import time
7
+ from email.utils import parsedate_to_datetime
5
8
  from urllib.error import HTTPError
6
9
  from urllib.parse import urlencode
7
10
  from urllib.request import Request, urlopen
8
11
 
9
12
  from ebird.api.requests import constants
10
13
 
14
+ # The maximum interval, in seconds, the rate limiter will slow down to
15
+ # after the eBird API responds with HTTP 429 Too Many Requests.
16
+ MAX_RATE_LIMIT_INTERVAL = 60.0
17
+
18
+
19
+ class RateLimitError(HTTPError):
20
+ """Raised when the eBird API still responds with HTTP 429 Too Many
21
+ Requests after all the retries have been used.
22
+
23
+ It is a subclass of HTTPError so existing error handling still works.
24
+ The retry_after attribute contains the number of seconds the API asked
25
+ the caller to wait, or None if no Retry-After header was returned.
26
+
27
+ """
28
+
29
+ def __init__(self, exc, retry_after=None):
30
+ super().__init__(exc.url, exc.code, exc.msg, exc.headers, exc.fp)
31
+ self.retry_after = retry_after
32
+
33
+
34
+ class RateLimiter:
35
+ """Enforce a minimum interval between the start of successive requests.
36
+
37
+ An interval of zero (the default) disables the limiter.
38
+
39
+ """
40
+
41
+ def __init__(self, interval=0.0):
42
+ self.interval = float(interval)
43
+ self.last_request = None
44
+ self.lock = threading.Lock()
45
+
46
+ def wait(self):
47
+ with self.lock:
48
+ if self.interval > 0 and self.last_request is not None:
49
+ delay = self.interval - (time.monotonic() - self.last_request)
50
+ if delay > 0:
51
+ time.sleep(delay)
52
+ self.last_request = time.monotonic()
53
+
54
+ def slow_down(self):
55
+ with self.lock:
56
+ if self.interval > 0:
57
+ self.interval = min(self.interval * 2, MAX_RATE_LIMIT_INTERVAL)
58
+
59
+
60
+ _rate_limiter = RateLimiter()
61
+
62
+
63
+ def set_rate_limit(interval):
64
+ """Set the minimum interval, in seconds, between requests to the eBird API.
65
+
66
+ The limit applies to all requests made by the process. Setting it to
67
+ zero, the default, disables rate limiting. If the API responds with
68
+ HTTP 429 Too Many Requests, the interval is doubled, up to a maximum
69
+ of MAX_RATE_LIMIT_INTERVAL, for the rest of the process, or until the
70
+ rate limit is set again.
71
+
72
+ :param interval: the number of seconds between requests.
73
+ :type interval: float
74
+
75
+ """
76
+ if interval < 0:
77
+ raise ValueError("The rate limit interval cannot be negative")
78
+ with _rate_limiter.lock:
79
+ _rate_limiter.interval = float(interval)
80
+
81
+
82
+ def get_rate_limit():
83
+ """Get the current minimum interval, in seconds, between requests.
84
+
85
+ :return: the interval, which may have increased since it was set, if the
86
+ eBird API responded with HTTP 429 Too Many Requests.
87
+ :rtype: float
88
+
89
+ """
90
+ return _rate_limiter.interval
91
+
92
+
93
+ def get_retry_after(exc):
94
+ """Get the number of seconds to wait from a Retry-After header.
95
+
96
+ :param exc: the error raised for the HTTP 429 response.
97
+ :type exc: HTTPError
98
+
99
+ :return: the number of seconds to wait, or None if the header was not
100
+ present or could not be parsed.
101
+ :rtype: float | None
102
+
103
+ """
104
+ value = exc.headers.get("Retry-After") if exc.headers else None
105
+ if not value:
106
+ return None
107
+ try:
108
+ return max(float(value), 0.0)
109
+ except ValueError:
110
+ pass
111
+ try:
112
+ when = parsedate_to_datetime(value)
113
+ except (TypeError, ValueError):
114
+ return None
115
+ if when.tzinfo is None:
116
+ when = when.replace(tzinfo=dt.timezone.utc)
117
+ return max((when - dt.datetime.now(dt.timezone.utc)).total_seconds(), 0.0)
118
+
119
+
11
120
  _parameter_defaults = {
12
121
  "back": constants.DEFAULT_BACK,
13
122
  "cat": constants.DEFAULT_SPECIES_CATEGORY,
@@ -101,6 +210,9 @@ def get_response(url, params=None, headers=None, max_retries=3, backoff_factor=1
101
210
  :raises URLError if there is an error with the connection to the
102
211
  eBird site.
103
212
 
213
+ :raises RateLimitError if the eBird API still returns HTTP 429 Too Many
214
+ Requests after all the retries have been used.
215
+
104
216
  :raises HTTPError if the eBird API returns an error.
105
217
 
106
218
  """
@@ -114,14 +226,21 @@ def get_response(url, params=None, headers=None, max_retries=3, backoff_factor=1
114
226
  request.add_header(name, value)
115
227
 
116
228
  for attempt in range(max_retries + 1):
229
+ _rate_limiter.wait()
117
230
  try:
118
231
  return urlopen(request).read()
119
232
  except HTTPError as exc:
120
- if attempt == max_retries:
233
+ retry_after = None
234
+ if exc.code == 429:
235
+ retry_after = get_retry_after(exc)
236
+ _rate_limiter.slow_down()
237
+ if attempt == max_retries:
238
+ raise RateLimitError(exc, retry_after) from exc
239
+ elif attempt == max_retries:
121
240
  raise
122
- retry_after = exc.headers.get("Retry-After") if exc.code == 429 else None
123
- delay = float(retry_after) if retry_after else backoff_factor * (2**attempt)
124
- time.sleep(delay)
241
+ if retry_after is None:
242
+ retry_after = backoff_factor * (2**attempt)
243
+ time.sleep(retry_after)
125
244
 
126
245
 
127
246
  def get_json(content):
@@ -3,12 +3,14 @@
3
3
  """Functions for validation."""
4
4
 
5
5
  import re
6
+ import warnings
6
7
  from datetime import date, datetime
7
8
  from enum import Enum
8
9
 
9
10
  from ebird.api.requests import constants
10
11
 
11
12
  _locales = constants.LOCALES.values()
13
+ _observation_rank = ", ".join(constants.OBSERVATION_RANK)
12
14
  _region_types = ", ".join(constants.REGION_TYPES)
13
15
  _sort_list = ", ".join(constants.SPECIES_SORT)
14
16
  _species_categories = ", ".join(constants.SPECIES_CATEGORIES)
@@ -38,7 +40,7 @@ def is_region(value):
38
40
  return (
39
41
  re.match(r"^[A-Z]{2}$", value)
40
42
  or re.match(r"^[A-Z]{2}-[A-Z0-9]{1,3}$", value)
41
- or re.match(r"^[A-Z]{2}-[A-Z0-9]{2,3}-[A-Z0-9]{2,3}$", value)
43
+ or re.match(r"^[A-Z]{2}-[A-Z0-9]{1,3}-[A-Z0-9]{2,3}$", value)
42
44
  )
43
45
 
44
46
 
@@ -200,6 +202,15 @@ def clean_detail(value):
200
202
  return cleaned
201
203
 
202
204
 
205
+ def clean_observation_rank(value):
206
+ cleaned = clean_code(value, transform=Transform.LOWER)
207
+ if cleaned not in constants.OBSERVATION_RANK:
208
+ raise ValueError(
209
+ "Value for 'rank', %s, must be one of: %s" % (value, _observation_rank)
210
+ )
211
+ return cleaned
212
+
213
+
203
214
  def clean_provisional(value):
204
215
  return "true" if bool(value) else "false"
205
216
 
@@ -339,10 +350,21 @@ def clean_date(value):
339
350
  return cleaned.strftime("%Y/%m/%d")
340
351
 
341
352
 
342
- def clean_rank(value):
353
+ def clean_observer_rank(value):
343
354
  cleaned = clean_code(value, transform=Transform.LOWER)
344
355
  if cleaned not in constants.TOP_100_RANK:
345
356
  raise ValueError(
346
357
  "Top 100 rank, %s, must be one or more of : %s" % (value, _top_100_rank)
347
358
  )
348
359
  return cleaned
360
+
361
+
362
+ def clean_rank(value):
363
+ # Deprecated in favour of clean_observer_rank(). Remove in 5.0.0.
364
+ warnings.warn(
365
+ "clean_rank() is deprecated and will be removed in 5.0.0. "
366
+ "Use clean_observer_rank() instead.",
367
+ DeprecationWarning,
368
+ stacklevel=2,
369
+ )
370
+ return clean_observer_rank(value)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ebird-api-requests
3
- Version: 4.1.1
3
+ Version: 4.3.0
4
4
  Summary: A python library for accessing the eBird API, version 2.0
5
5
  Author-email: Stuart MacKay <smackay@flagstonesoftware.com>
6
6
  License: MIT License
@@ -346,6 +346,30 @@ client.get_observations('MX-OAX')
346
346
 
347
347
  The client supports all the API functions.
348
348
 
349
+ ### Rate Limiting
350
+
351
+ The eBird API limits how quickly requests can be made. If you exceed the
352
+ limit the API responds with HTTP 429 Too Many Requests. If you are making
353
+ lots of calls, for example downloading all the checklists for a region,
354
+ then set a minimum interval, in seconds, between requests:
355
+
356
+ ```python
357
+ from ebird.api.requests import set_rate_limit
358
+
359
+ set_rate_limit(1.0)
360
+ ```
361
+
362
+ The limit applies to all the requests made by the process. It is disabled
363
+ by default. If the API still responds with HTTP 429, the interval is doubled,
364
+ up to a maximum of 60 seconds, so the requests slow down to a rate the API
365
+ accepts. Use get_rate_limit() to see the current interval.
366
+
367
+ When an HTTP 429 response is returned, the request is retried after waiting
368
+ for the time given in the Retry-After header. If the request still fails
369
+ after all the retries are used, a RateLimitError is raised. It is a subclass
370
+ of HTTPError, and the retry_after attribute contains the number of seconds
371
+ the API asked you to wait, or None if no Retry-After header was returned.
372
+
349
373
  ## Formats
350
374
 
351
375
  Most of the eBird API calls return JSON. Some of the calls such as getting