ebird-cli 0.1.0__py3-none-any.whl

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.
ebird_cli/main.py ADDED
@@ -0,0 +1,252 @@
1
+ import argparse
2
+ import os
3
+ import re
4
+ import ssl
5
+ import urllib.request
6
+ from importlib.metadata import version
7
+
8
+ import certifi
9
+ from appdirs import user_cache_dir
10
+ from colorama import Fore
11
+ from prompt_toolkit import PromptSession
12
+ from prompt_toolkit.key_binding import KeyBindings
13
+ from prompt_toolkit.keys import Keys
14
+ from prompt_toolkit.styles import Style
15
+
16
+ from .cli.autocomplete import ContextSensitiveCompleter
17
+ from .cli.command import NotableCommand, RecentCommand
18
+ from .cli.grammar import CommandLineError
19
+ from .domain.region import Region
20
+ from .services.cache import CacheService
21
+ from .services.ebird_client import EbirdClient
22
+ from .services.location_catalogue import LocationCatalogue
23
+ from .services.observation_query import ObservationQuery
24
+ from .services.printing import PrintingService
25
+ from .services.taxonomy import TaxonomyService
26
+ from .utils.logger import configure_logging
27
+
28
+ api_key_env_variable = "EBIRDAPIKEY"
29
+ locale_env_variable = "EBIRDLOCALE"
30
+ default_region_env_variable = "EBIRDDEFAULTREGION"
31
+ year_list_env_variable = "EBIRDYEARLIST"
32
+ life_list_env_variable = "EBIRDLIFELIST"
33
+ lat_env_variable = "EBIRDLAT"
34
+ long_env_variable = "EBIRDLONG"
35
+ aggregations_env_variable = "EBIRDAGGREGATIONS"
36
+
37
+ region_regex = "([A-Z]{2}-){2}[A-Z]{2}"
38
+
39
+
40
+ def regex_type(pattern: str | re.Pattern):
41
+ def closure_check_regex(arg_value):
42
+ if not re.match(pattern, arg_value):
43
+ raise argparse.ArgumentTypeError("invalid value")
44
+ return arg_value
45
+
46
+ return closure_check_regex
47
+
48
+
49
+ def print_menu(commands, args=None):
50
+ examples = []
51
+
52
+ for value in commands.values():
53
+ examples.append(value.command_example())
54
+ formatted_examples = "\n ".join(f"{item}" for item in examples)
55
+
56
+ args_section = ""
57
+ if args is not None:
58
+ arg_lines = [
59
+ f"{Fore.BLUE}region{Fore.RESET}: {args.region}",
60
+ f"{Fore.BLUE}locale{Fore.RESET}: {args.locale}",
61
+ ]
62
+ args_section = "\n Arguments:\n \n " + "\n ".join(arg_lines) + "\n "
63
+
64
+ menu = f"""
65
+ {Fore.GREEN}eBird CLI{Fore.RESET}
66
+ {args_section}
67
+ Available commands:
68
+
69
+ {formatted_examples}
70
+ """
71
+
72
+ print(menu)
73
+
74
+
75
+ def setup_key_bindings():
76
+ bindings = KeyBindings()
77
+
78
+ @bindings.add(Keys.Tab)
79
+ def handle_tab(event):
80
+ buffer = event.app.current_buffer
81
+ if buffer.complete_state:
82
+ buffer.complete_next()
83
+ else:
84
+ buffer.start_completion(select_first=True)
85
+
86
+ return bindings
87
+
88
+
89
+ def use_certifi_ca_bundle():
90
+ # ebird-api calls urllib's urlopen, which on Windows only trusts roots already in the Windows store.
91
+ # Windows downloads roots on demand, so eBird's root may be missing; use certifi's bundle like requests does.
92
+ context = ssl.create_default_context(cafile=certifi.where())
93
+ urllib.request.install_opener(urllib.request.build_opener(urllib.request.HTTPSHandler(context=context)))
94
+
95
+
96
+ def setup_parser(parser: argparse.ArgumentParser):
97
+ parser.add_argument("--version", action="version", version=f"ebird-cli {version('ebird-cli')}")
98
+
99
+ parser.add_argument(
100
+ "--api-key",
101
+ default=os.getenv(api_key_env_variable),
102
+ required=os.getenv(api_key_env_variable) is None,
103
+ help=f"eBird API key (mandatory if {api_key_env_variable} env variable is not set)",
104
+ )
105
+
106
+ parser.add_argument(
107
+ "--region",
108
+ default=os.getenv(default_region_env_variable),
109
+ required=os.getenv(default_region_env_variable) is None,
110
+ help=(
111
+ "eBird subnational level 2 region code is mandatory if "
112
+ f"{default_region_env_variable} env variable is not set"
113
+ ),
114
+ type=regex_type(region_regex),
115
+ )
116
+
117
+ parser.add_argument(
118
+ "--locale",
119
+ type=str,
120
+ default=os.getenv(locale_env_variable) if os.getenv(locale_env_variable) is not None else "fr",
121
+ help="Locale",
122
+ )
123
+
124
+ parser.add_argument(
125
+ "--lat",
126
+ type=str,
127
+ default=os.getenv(lat_env_variable),
128
+ help="Latitude",
129
+ )
130
+
131
+ parser.add_argument(
132
+ "--long",
133
+ type=str,
134
+ default=os.getenv(long_env_variable),
135
+ help="Longitude",
136
+ )
137
+
138
+ parser.add_argument(
139
+ "--year-list",
140
+ type=str,
141
+ default=os.getenv(year_list_env_variable),
142
+ help="List of observations for the current year",
143
+ )
144
+
145
+ parser.add_argument(
146
+ "--life-list",
147
+ type=str,
148
+ default=os.getenv(life_list_env_variable),
149
+ help="List of lifetime observations",
150
+ )
151
+
152
+ parser.add_argument(
153
+ "--aggregations",
154
+ type=str,
155
+ default=os.getenv(aggregations_env_variable),
156
+ help=f"Path to aggregations JSON file (custom hotspot groups). From {aggregations_env_variable} env var",
157
+ )
158
+
159
+
160
+ def main():
161
+ configure_logging()
162
+
163
+ parser = argparse.ArgumentParser(description="eBird CLI")
164
+ setup_parser(parser)
165
+
166
+ args = parser.parse_args()
167
+
168
+ api_key = args.api_key
169
+ region = args.region
170
+ locale = args.locale
171
+ lat = args.lat
172
+ long = args.long
173
+
174
+ life_list = args.life_list or None
175
+ year_list = args.year_list or None
176
+ aggregations = args.aggregations or None
177
+
178
+ use_certifi_ca_bundle()
179
+
180
+ client = EbirdClient(api_key, locale)
181
+ cache_dir = user_cache_dir("ebird_cli")
182
+
183
+ cache_service = CacheService(api_key, locale, Region(region), api_client=client, cache_dir=cache_dir)
184
+ printing_service = PrintingService(life_list, year_list)
185
+ location_catalogue = LocationCatalogue.load(Region(region), client, cache_dir, aggregations)
186
+ observation_query = ObservationQuery(client, location_catalogue, lat, long)
187
+ taxonomy_service = TaxonomyService(cache_service.taxonomy_cache)
188
+
189
+ commands = {
190
+ command.command_name: command
191
+ for command in [
192
+ RecentCommand(observation_query, location_catalogue, printing_service, taxonomy_service),
193
+ NotableCommand(observation_query, location_catalogue, printing_service),
194
+ ]
195
+ }
196
+
197
+ style = Style.from_dict(
198
+ {
199
+ "prompt": "ansigreen bold",
200
+ "": "ansiwhite",
201
+ "completion-menu.completion": "bg:#008888 #ffffff",
202
+ "completion-menu.completion.current": "bg:#00aaaa #000000",
203
+ }
204
+ )
205
+
206
+ print_menu(commands, args)
207
+ session = PromptSession(completer=ContextSensitiveCompleter(commands.values()), key_bindings=setup_key_bindings())
208
+
209
+ while True:
210
+ try:
211
+ print("")
212
+ user_input = session.prompt("⋙ ", style=style)
213
+ if not handle_input(user_input, commands, args):
214
+ break
215
+ except KeyboardInterrupt:
216
+ continue
217
+ except EOFError:
218
+ break
219
+
220
+
221
+ def handle_input(user_input: str, commands, program_args) -> bool:
222
+ """Run one line typed at the prompt. Returns whether the session should continue."""
223
+ try:
224
+ if user_input.lower() == "exit" or user_input.lower() == "e":
225
+ print("Exiting eBird CLI.")
226
+ return False
227
+
228
+ if user_input == "":
229
+ print_menu(commands, program_args)
230
+ return True
231
+
232
+ words = user_input.split()
233
+ if not words:
234
+ return True
235
+
236
+ command_name, *command_args = words
237
+ user_request = commands.get(command_name)
238
+
239
+ if user_request:
240
+ user_request.handle_command(*command_args)
241
+ else:
242
+ print("Unknown command.")
243
+ except CommandLineError as e:
244
+ print(f"Invalid argument: {e.message}")
245
+ except Exception as e:
246
+ print(f"An error occurred: {e}")
247
+
248
+ return True
249
+
250
+
251
+ if __name__ == "__main__":
252
+ main()
@@ -0,0 +1,4 @@
1
+ from .location_catalogue import LocationCatalogue
2
+ from .taxonomy import TaxonomyService
3
+
4
+ __all__ = ["LocationCatalogue", "TaxonomyService"]
@@ -0,0 +1,55 @@
1
+ import csv
2
+ import os
3
+
4
+ import pandas as pd
5
+ from appdirs import user_cache_dir
6
+
7
+ from ..domain.fields import EbirdFields
8
+ from ..domain.region import Region
9
+ from ..domain.taxonomy_cache import TaxonomyCache
10
+ from .ebird_client import EbirdClient
11
+
12
+ TAXONOMY_DIR = "taxonomy"
13
+
14
+
15
+ def write_csv(file_path, data):
16
+ with open(file_path, mode="w", newline="", encoding="utf-8") as csvfile:
17
+ fieldnames = list(dict.fromkeys(k for row in data for k in row))
18
+ writer = csv.DictWriter(
19
+ csvfile,
20
+ fieldnames=fieldnames,
21
+ quoting=csv.QUOTE_MINIMAL,
22
+ extrasaction="ignore",
23
+ )
24
+
25
+ writer.writeheader()
26
+ writer.writerows(data)
27
+
28
+
29
+ class CacheService:
30
+ def __init__(self, api_key: str, locale: str, region: Region, api_client=None, cache_dir: str | None = None):
31
+ self.api_client = api_client or EbirdClient(api_key, locale)
32
+ self.cache_dir = cache_dir or user_cache_dir("ebird_cli")
33
+
34
+ national_taxonomy_path = self.get_taxonomy(region)
35
+
36
+ self.taxonomy_cache = TaxonomyCache(region, national_taxonomy_path)
37
+
38
+ def get_taxonomy(self, region: Region) -> str:
39
+ taxonomy_path = os.path.join(self.cache_dir, TAXONOMY_DIR, "taxonomy.csv")
40
+ national_taxonomy_path = os.path.join(self.cache_dir, TAXONOMY_DIR, region.national, "taxonomy.csv")
41
+
42
+ if not os.path.exists(taxonomy_path):
43
+ taxonomy = self.api_client.get_ebird_taxonomy()
44
+ os.makedirs(os.path.dirname(taxonomy_path), exist_ok=True)
45
+ write_csv(taxonomy_path, taxonomy)
46
+
47
+ taxonomy_df = pd.read_csv(taxonomy_path)
48
+
49
+ if not os.path.exists(national_taxonomy_path):
50
+ national_species = self.api_client.get_national_species(region.national)
51
+ national_taxonomy_df = taxonomy_df[taxonomy_df[EbirdFields.species_code].isin(national_species)]
52
+ os.makedirs(os.path.dirname(national_taxonomy_path), exist_ok=True)
53
+ national_taxonomy_df.to_csv(national_taxonomy_path, index=False)
54
+
55
+ return national_taxonomy_path
@@ -0,0 +1,14 @@
1
+ import pandas
2
+
3
+ from ..domain.fields import ExportFields
4
+
5
+
6
+ class DataFrameService:
7
+ def is_csv(self, filename) -> bool:
8
+ return filename[-3:] == "csv"
9
+
10
+ def get_dataframe(self, filepath) -> pandas.DataFrame:
11
+ if filepath and self.is_csv(filepath):
12
+ return pandas.read_csv(filepath)
13
+ else:
14
+ return pandas.DataFrame(columns=[ExportFields.common_name])
@@ -0,0 +1,111 @@
1
+ import requests
2
+ from ebird.api import Client, constants, observations
3
+
4
+
5
+ class EbirdClient(Client):
6
+ """The ebird-api client plus the reference endpoints it doesn't wrap the way the CLI calls them.
7
+
8
+ Client reads `back` and `hotspot` from its own attributes; the observation methods here take them
9
+ per call instead, so callers never have to change the client's state before a request.
10
+ """
11
+
12
+ def __init__(self, api_key: str, locale: str):
13
+ super().__init__(api_key, locale)
14
+ self.detail = "full"
15
+ # Client.locale holds the validated form; the taxonomy endpoint has always been sent the locale as given.
16
+ self.taxonomy_locale = locale
17
+
18
+ def get_observations(self, area, back=constants.DEFAULT_BACK, hotspot=False):
19
+ return observations.get_observations(
20
+ self.api_key,
21
+ area,
22
+ back=back,
23
+ max_results=self.max_observations,
24
+ locale=self.locale,
25
+ provisional=self.provisional,
26
+ hotspot=hotspot,
27
+ detail=self.detail,
28
+ category=self.category,
29
+ )
30
+
31
+ def get_notable_observations(self, area, back=constants.DEFAULT_BACK, hotspot=False):
32
+ return observations.get_notable_observations(
33
+ self.api_key,
34
+ area,
35
+ back=back,
36
+ max_results=self.max_observations,
37
+ locale=self.locale,
38
+ hotspot=hotspot,
39
+ detail=self.detail,
40
+ )
41
+
42
+ def get_species_observations(self, species, area, back=constants.DEFAULT_BACK, hotspot=False):
43
+ return observations.get_species_observations(
44
+ self.api_key,
45
+ species,
46
+ area,
47
+ back=back,
48
+ max_results=self.max_observations,
49
+ locale=self.locale,
50
+ provisional=self.provisional,
51
+ hotspot=hotspot,
52
+ detail=self.detail,
53
+ category=self.category,
54
+ )
55
+
56
+ def get_nearby_observations(self, lat, lng, dist=25, back=constants.DEFAULT_BACK, hotspot=False):
57
+ return observations.get_nearby_observations(
58
+ self.api_key,
59
+ lat,
60
+ lng,
61
+ dist=dist,
62
+ back=back,
63
+ max_results=self.max_observations,
64
+ locale=self.locale,
65
+ provisional=self.provisional,
66
+ hotspot=hotspot,
67
+ category=self.category,
68
+ )
69
+
70
+ def get_nearby_notable(self, lat, lng, dist=25, back=constants.DEFAULT_BACK, hotspot=False):
71
+ return observations.get_nearby_notable(
72
+ self.api_key,
73
+ lat,
74
+ lng,
75
+ dist=dist,
76
+ back=back,
77
+ max_results=self.max_observations,
78
+ locale=self.locale,
79
+ hotspot=hotspot,
80
+ detail=self.detail,
81
+ )
82
+
83
+ def get_nearby_species(self, species, lat, lng, dist=25, back=constants.DEFAULT_BACK, hotspot=False):
84
+ return observations.get_nearby_species(
85
+ self.api_key,
86
+ species,
87
+ lat,
88
+ lng,
89
+ dist=dist,
90
+ back=back,
91
+ max_results=self.max_observations,
92
+ locale=self.locale,
93
+ provisional=self.provisional,
94
+ hotspot=hotspot,
95
+ category=self.category,
96
+ )
97
+
98
+ def get_ebird_taxonomy(self) -> list[dict]:
99
+ url = "https://api.ebird.org/v2/ref/taxonomy/ebird"
100
+ headers = {"X-eBirdApiToken": self.api_key}
101
+ params = {"fmt": "json", "locale": self.taxonomy_locale}
102
+ response = requests.get(url, headers=headers, params=params)
103
+ response.raise_for_status()
104
+ return response.json()
105
+
106
+ def get_national_species(self, country: str) -> list[str]:
107
+ url = f"https://api.ebird.org/v2/product/spplist/{country}"
108
+ headers = {"X-eBirdApiToken": self.api_key}
109
+ response = requests.get(url, headers=headers)
110
+ response.raise_for_status()
111
+ return response.json()
@@ -0,0 +1,121 @@
1
+ import json
2
+ import os
3
+ import time
4
+ from collections.abc import Callable
5
+ from datetime import timedelta
6
+ from urllib.error import HTTPError
7
+
8
+ from ..domain.fields import EbirdFields
9
+ from ..domain.region import Region
10
+ from ..domain.regional_scopes import RegionalScopes
11
+
12
+ LOCATION_DIR = "location"
13
+ # Hotspots are created all the time; Regions practically never change, so their files don't expire.
14
+ HOTSPOTS_MAX_AGE = timedelta(days=30)
15
+
16
+
17
+ def is_fresh(file_path: str, max_age: timedelta | None) -> bool:
18
+ """Whether the file exists and, given a max age, was written no longer ago than that."""
19
+ if not os.path.exists(file_path):
20
+ return False
21
+ return max_age is None or time.time() - os.path.getmtime(file_path) <= max_age.total_seconds()
22
+
23
+
24
+ def cached(file_path: str, fetch: Callable[[], list[dict]], max_age: timedelta | None = None) -> list[dict]:
25
+ """The eBird response cached at file_path, fetched and stored verbatim first if it isn't there or is too old.
26
+
27
+ A file that is too old is still used when eBird can't be reached, but not when eBird refuses the request.
28
+ """
29
+ if not is_fresh(file_path, max_age):
30
+ try:
31
+ response = fetch()
32
+ except HTTPError:
33
+ raise
34
+ except OSError: # URLError and timeouts both derive from it
35
+ if not os.path.exists(file_path):
36
+ raise
37
+ else:
38
+ os.makedirs(os.path.dirname(file_path), exist_ok=True)
39
+ # Write beside the file and swap it in, so an interrupted write can't leave a broken cache behind.
40
+ partial_path = file_path + ".partial"
41
+ with open(partial_path, "w", encoding="utf-8") as file:
42
+ json.dump(response, file, ensure_ascii=False, indent=2)
43
+ os.replace(partial_path, file_path)
44
+ with open(file_path, encoding="utf-8") as file:
45
+ return json.load(file)
46
+
47
+
48
+ def read_aggregations(aggregations_path: str | None) -> dict[str, list[str]]:
49
+ """Aggregation name to Hotspot ids, merged from every object in the file; a missing file means none."""
50
+ if not aggregations_path:
51
+ return {}
52
+ file_path = os.path.expanduser(aggregations_path)
53
+ if not os.path.isfile(file_path):
54
+ return {}
55
+ with open(file_path, encoding="utf-8") as file:
56
+ aggregations = {}
57
+ for aggregation in json.load(file):
58
+ aggregations.update(aggregation)
59
+ return aggregations
60
+
61
+
62
+ class LocationCatalogue:
63
+ """Every Location the user can name around their Home Region, by Regional scope."""
64
+
65
+ def __init__(
66
+ self,
67
+ home_region: Region,
68
+ subnationals: list[dict],
69
+ regionals: list[dict],
70
+ hotspots: list[dict],
71
+ aggregations: dict[str, list[str]],
72
+ ):
73
+ self.home_codes = {
74
+ RegionalScopes.SUBNATIONAL: home_region.subnational,
75
+ RegionalScopes.REGIONAL: home_region.regional,
76
+ RegionalScopes.HOTSPOT: home_region.regional,
77
+ }
78
+ self.locations: dict[RegionalScopes, list[tuple[str, list[str]]]] = {
79
+ RegionalScopes.SUBNATIONAL: [(row[EbirdFields.name], [row[EbirdFields.code]]) for row in subnationals],
80
+ RegionalScopes.REGIONAL: [(row[EbirdFields.name], [row[EbirdFields.code]]) for row in regionals],
81
+ RegionalScopes.HOTSPOT: [
82
+ *((row[EbirdFields.location_name], [row[EbirdFields.location_id]]) for row in hotspots),
83
+ *aggregations.items(),
84
+ ],
85
+ }
86
+
87
+ @classmethod
88
+ def load(cls, home_region: Region, client, cache_dir: str, aggregations_path: str | None = None):
89
+ """Read the Home Region's Locations from the cache, fetching them from eBird when missing."""
90
+ national_dir = os.path.join(cache_dir, LOCATION_DIR, home_region.national)
91
+ subnational_dir = os.path.join(national_dir, home_region.subnational)
92
+ return cls(
93
+ home_region,
94
+ cached(
95
+ os.path.join(national_dir, "subnationals.json"),
96
+ lambda: client.get_regions("subnational1", home_region.national),
97
+ ),
98
+ cached(
99
+ os.path.join(subnational_dir, "regionals.json"),
100
+ lambda: client.get_regions("subnational2", home_region.subnational),
101
+ ),
102
+ cached(
103
+ os.path.join(subnational_dir, "hotspots.json"),
104
+ # Without back, eBird returns every Hotspot in the Region, not only the recently visited ones.
105
+ lambda: client.get_hotspots(home_region.subnational, back=None),
106
+ HOTSPOTS_MAX_AGE,
107
+ ),
108
+ read_aggregations(aggregations_path),
109
+ )
110
+
111
+ def names(self, scope: RegionalScopes, query: str = "") -> list[str]:
112
+ """Names of the scope's Locations containing the query, ignoring case; nearby has none."""
113
+ return [name for name, _ in self.locations.get(scope, []) if query.lower() in name.lower()]
114
+
115
+ def resolve(self, scope: RegionalScopes, name: str | None = None) -> list[str]:
116
+ """Location ids of the scope's Locations with exactly this name; no name means the Home Region at that scope."""
117
+ if scope not in self.locations:
118
+ raise ValueError(f"{scope} is not a scope of named Locations")
119
+ if name is None:
120
+ return [self.home_codes[scope]]
121
+ return [location_id for candidate, ids in self.locations[scope] if candidate == name for location_id in ids]
@@ -0,0 +1,102 @@
1
+ from collections.abc import Callable, Hashable
2
+ from enum import StrEnum
3
+
4
+ from ..domain import Observation
5
+ from ..domain.regional_scopes import RegionalScopes
6
+ from .location_catalogue import LocationCatalogue
7
+
8
+ DEFAULT_DAYS = 7
9
+ NEARBY_DISTANCE_KM = 50
10
+ # Nearby species queries have always used the eBird client's default radius rather than NEARBY_DISTANCE_KM.
11
+ NEARBY_SPECIES_DISTANCE_KM = 25
12
+
13
+
14
+ class ObservationKind(StrEnum):
15
+ RECENT = "recent"
16
+ NOTABLE = "notable"
17
+
18
+
19
+ def latest_per(key: Callable[[Observation], Hashable], observations: list[Observation]) -> list[Observation]:
20
+ """The latest observation for each key, oldest first."""
21
+ latest = {}
22
+ for obs in observations:
23
+ if key(obs) not in latest or latest[key(obs)].observation_datetime < obs.observation_datetime:
24
+ latest[key(obs)] = obs
25
+ return sorted(latest.values(), key=lambda x: x.observation_datetime)
26
+
27
+
28
+ def first_per(key: Callable[[Observation], Hashable], observations: list[Observation]) -> list[Observation]:
29
+ """The first observation eBird returned for each key, oldest first."""
30
+ first = {}
31
+ for obs in observations:
32
+ first.setdefault(key(obs), obs)
33
+ return sorted(first.values(), key=lambda x: x.observation_datetime)
34
+
35
+
36
+ class ObservationQuery:
37
+ """Observations of a kind at a Location or nearby, from whichever eBird request that takes."""
38
+
39
+ def __init__(self, client, location_catalogue: LocationCatalogue, lat: str | None, long: str | None):
40
+ self.client = client
41
+ self.location_catalogue = location_catalogue
42
+ self.lat = lat
43
+ self.long = long
44
+
45
+ def observations(
46
+ self,
47
+ kind: ObservationKind,
48
+ scope: RegionalScopes,
49
+ region: str | None = None,
50
+ species_code: str | None = None,
51
+ back=DEFAULT_DAYS,
52
+ ) -> list[Observation]:
53
+ """Region None means the Home Region at that scope; nearby ignores it and needs the user's coordinates."""
54
+ if kind == ObservationKind.NOTABLE:
55
+ if species_code is not None:
56
+ raise ValueError("notable observations can't be narrowed to a species")
57
+ return first_per(lambda obs: (obs.location, obs.name), self.notable(scope, region, back))
58
+ if species_code is not None:
59
+ return latest_per(lambda obs: obs.location, self.species(scope, region, species_code, back))
60
+ return latest_per(lambda obs: obs.name, self.recent(scope, region, back))
61
+
62
+ def recent(self, scope: RegionalScopes, region: str | None, back) -> list[Observation]:
63
+ """Every species reported, as eBird returned them."""
64
+ if scope == RegionalScopes.NEARBY:
65
+ results = self.client.get_nearby_observations(
66
+ *self.coordinates(), dist=NEARBY_DISTANCE_KM, back=back, hotspot=True
67
+ )
68
+ else:
69
+ results = self.client.get_observations(self.location_ids(scope, region), back=back, hotspot=True)
70
+ return [Observation(result) for result in results]
71
+
72
+ def notable(self, scope: RegionalScopes, region: str | None, back) -> list[Observation]:
73
+ """Notable species reported, as eBird returned them."""
74
+ if scope == RegionalScopes.NEARBY:
75
+ results = self.client.get_nearby_notable(
76
+ *self.coordinates(), dist=NEARBY_DISTANCE_KM, back=back, hotspot=True
77
+ )
78
+ else:
79
+ results = self.client.get_notable_observations(self.location_ids(scope, region), back=back, hotspot=True)
80
+ return [Observation(result) for result in results]
81
+
82
+ def species(self, scope: RegionalScopes, region: str | None, species_code: str, back) -> list[Observation]:
83
+ """Reports of one species, as eBird returned them."""
84
+ if scope == RegionalScopes.NEARBY:
85
+ results = self.client.get_nearby_species(
86
+ species_code, *self.coordinates(), dist=NEARBY_SPECIES_DISTANCE_KM, back=back, hotspot=True
87
+ )
88
+ else:
89
+ results = self.client.get_species_observations(
90
+ species_code, self.location_ids(scope, region), back=back, hotspot=True
91
+ )
92
+ return [Observation(result) for result in results]
93
+
94
+ def coordinates(self) -> tuple[str, str]:
95
+ """The user's coordinates, which every nearby query needs."""
96
+ if self.lat is None or self.long is None:
97
+ raise ValueError("nearby needs coordinates: set --lat and --long, or EBIRDLAT and EBIRDLONG")
98
+ return self.lat, self.long
99
+
100
+ def location_ids(self, scope: RegionalScopes, region: str | None) -> list[str]:
101
+ """Location ids the region resolves to, each once."""
102
+ return list(dict.fromkeys(self.location_catalogue.resolve(scope, region)))