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/__init__.py +0 -0
- ebird_cli/cli/__init__.py +3 -0
- ebird_cli/cli/autocomplete.py +30 -0
- ebird_cli/cli/command.py +150 -0
- ebird_cli/cli/grammar.py +197 -0
- ebird_cli/domain/__init__.py +4 -0
- ebird_cli/domain/fields.py +20 -0
- ebird_cli/domain/observation.py +21 -0
- ebird_cli/domain/region.py +6 -0
- ebird_cli/domain/regional_scopes.py +9 -0
- ebird_cli/domain/taxonomy.py +36 -0
- ebird_cli/domain/taxonomy_cache.py +9 -0
- ebird_cli/download_lists.py +52 -0
- ebird_cli/main.py +252 -0
- ebird_cli/services/__init__.py +4 -0
- ebird_cli/services/cache.py +55 -0
- ebird_cli/services/dataframe.py +14 -0
- ebird_cli/services/ebird_client.py +111 -0
- ebird_cli/services/location_catalogue.py +121 -0
- ebird_cli/services/observation_query.py +102 -0
- ebird_cli/services/printing.py +53 -0
- ebird_cli/services/taxonomy.py +18 -0
- ebird_cli/utils/__init__.py +0 -0
- ebird_cli/utils/logger.py +17 -0
- ebird_cli-0.1.0.dist-info/METADATA +292 -0
- ebird_cli-0.1.0.dist-info/RECORD +30 -0
- ebird_cli-0.1.0.dist-info/WHEEL +5 -0
- ebird_cli-0.1.0.dist-info/entry_points.txt +3 -0
- ebird_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- ebird_cli-0.1.0.dist-info/top_level.txt +1 -0
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,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)))
|