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/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from prompt_toolkit.completion import Completer, Completion, WordCompleter
|
|
2
|
+
from prompt_toolkit.document import Document
|
|
3
|
+
|
|
4
|
+
exit_program = "exit"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ContextSensitiveCompleter(Completer):
|
|
8
|
+
def __init__(self, commands):
|
|
9
|
+
self.first_word_completer = WordCompleter(
|
|
10
|
+
[cmd.command_name for cmd in commands] + [exit_program], ignore_case=True
|
|
11
|
+
)
|
|
12
|
+
self.command_completers = {cmd.command_name: cmd for cmd in commands}
|
|
13
|
+
|
|
14
|
+
def get_completions(self, document: Document, complete_event):
|
|
15
|
+
text = document.text_before_cursor
|
|
16
|
+
words = text.split(" ")
|
|
17
|
+
if not words or (len(words) == 1 and not text.endswith(" ")):
|
|
18
|
+
for completion in self.first_word_completer.get_completions(document, complete_event):
|
|
19
|
+
yield Completion(
|
|
20
|
+
completion.text if completion.text == exit_program else completion.text + "",
|
|
21
|
+
start_position=completion.start_position,
|
|
22
|
+
)
|
|
23
|
+
else:
|
|
24
|
+
command_name = words[0]
|
|
25
|
+
command = self.command_completers.get(command_name)
|
|
26
|
+
if not command:
|
|
27
|
+
yield Completion("", start_position=-len(document.get_word_before_cursor()))
|
|
28
|
+
else:
|
|
29
|
+
for completion in command.get_completions(document, complete_event):
|
|
30
|
+
yield completion
|
ebird_cli/cli/command.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from colorama import Fore
|
|
4
|
+
from prompt_toolkit.document import Document
|
|
5
|
+
|
|
6
|
+
from ..domain.regional_scopes import RegionalScopes
|
|
7
|
+
from ..services.location_catalogue import LocationCatalogue
|
|
8
|
+
from ..services.observation_query import ObservationKind, ObservationQuery
|
|
9
|
+
from ..services.printing import PrintingService
|
|
10
|
+
from ..services.taxonomy import TaxonomyService
|
|
11
|
+
from ..utils.logger import logger
|
|
12
|
+
from .grammar import Arguments, Flag, Grammar, Positional
|
|
13
|
+
|
|
14
|
+
SCOPE = Positional("scope", choices=[scope.value for scope in RegionalScopes])
|
|
15
|
+
BACK = Flag("back", default="7", choices=range(1, 31))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def region_flag(location_catalogue: LocationCatalogue) -> Flag:
|
|
19
|
+
return Flag(
|
|
20
|
+
"region",
|
|
21
|
+
multi_word=True,
|
|
22
|
+
suggestions=lambda typed, arguments: location_catalogue.names(RegionalScopes(arguments["scope"]), typed),
|
|
23
|
+
hidden_when=lambda arguments: arguments.get("scope") == RegionalScopes.NEARBY,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def species_flag(taxonomy_service: TaxonomyService) -> Flag:
|
|
28
|
+
return Flag(
|
|
29
|
+
"species",
|
|
30
|
+
multi_word=True,
|
|
31
|
+
suggestions=lambda typed, _arguments: taxonomy_service.search_by_common_name(typed) if typed else [],
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Command:
|
|
36
|
+
def __init__(self, command_name: str, description: str, grammar: Grammar):
|
|
37
|
+
self.command_name = command_name
|
|
38
|
+
self.description = description
|
|
39
|
+
self.grammar = grammar
|
|
40
|
+
|
|
41
|
+
def run(self, arguments: Arguments):
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
|
|
44
|
+
def handle_command(self, *args):
|
|
45
|
+
logger.debug(f"user input: {args}")
|
|
46
|
+
self.run(self.grammar.parse(list(args)))
|
|
47
|
+
|
|
48
|
+
def get_completions(self, document: Document, _complete_event):
|
|
49
|
+
_command_name, _, text = document.text_before_cursor.partition(" ")
|
|
50
|
+
logger.debug(f"command get_completions: {text}")
|
|
51
|
+
yield from self.grammar.complete(text)
|
|
52
|
+
|
|
53
|
+
def command_example(self):
|
|
54
|
+
command_name = f"{Fore.RED}{self.command_name}{Fore.RESET}"
|
|
55
|
+
mandatory = [positional.name for positional in self.grammar.positionals]
|
|
56
|
+
optional = [flag.token for flag in self.grammar.flags]
|
|
57
|
+
mandatory_params = f"{Fore.MAGENTA}<{', '.join(mandatory)}>{Fore.RESET}" if mandatory else ""
|
|
58
|
+
optional_params = f"{Fore.CYAN}[{', '.join(optional)}]{Fore.RESET}" if optional else ""
|
|
59
|
+
|
|
60
|
+
return f"{command_name:18} {mandatory_params} {optional_params}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class ObservationCommandArgs:
|
|
65
|
+
region: str | None
|
|
66
|
+
scope: RegionalScopes
|
|
67
|
+
back: str
|
|
68
|
+
species: str | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ObservationCommand(Command):
|
|
72
|
+
kind: ObservationKind
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
command_name: str,
|
|
77
|
+
description: str,
|
|
78
|
+
grammar: Grammar,
|
|
79
|
+
observation_query: ObservationQuery,
|
|
80
|
+
printing_service: PrintingService,
|
|
81
|
+
):
|
|
82
|
+
super().__init__(command_name, description, grammar)
|
|
83
|
+
self.observation_query = observation_query
|
|
84
|
+
self.printing_service = printing_service
|
|
85
|
+
|
|
86
|
+
def run(self, arguments: Arguments):
|
|
87
|
+
logger.debug(f"run - arguments: {arguments}")
|
|
88
|
+
args = ObservationCommandArgs(
|
|
89
|
+
region=arguments["region"],
|
|
90
|
+
scope=RegionalScopes(arguments["scope"]),
|
|
91
|
+
back=arguments["back"],
|
|
92
|
+
species=arguments.get("species"),
|
|
93
|
+
)
|
|
94
|
+
self.handle_observation(args)
|
|
95
|
+
|
|
96
|
+
def handle_observation(self, args: ObservationCommandArgs):
|
|
97
|
+
self.print_observations(args)
|
|
98
|
+
|
|
99
|
+
def print_observations(self, args: ObservationCommandArgs, species_code: str | None = None):
|
|
100
|
+
observations = self.observation_query.observations(
|
|
101
|
+
self.kind, args.scope, args.region, species_code=species_code, back=args.back
|
|
102
|
+
)
|
|
103
|
+
self.printing_service.print_observations(observations)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class RecentCommand(ObservationCommand):
|
|
107
|
+
kind = ObservationKind.RECENT
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
observation_query: ObservationQuery,
|
|
112
|
+
location_catalogue: LocationCatalogue,
|
|
113
|
+
printing_service: PrintingService,
|
|
114
|
+
taxonomy_service: TaxonomyService,
|
|
115
|
+
):
|
|
116
|
+
super().__init__(
|
|
117
|
+
"recent",
|
|
118
|
+
"Retrieve recent observations for the specified region",
|
|
119
|
+
Grammar([SCOPE], [region_flag(location_catalogue), species_flag(taxonomy_service), BACK]),
|
|
120
|
+
observation_query,
|
|
121
|
+
printing_service,
|
|
122
|
+
)
|
|
123
|
+
self.taxonomy_service = taxonomy_service
|
|
124
|
+
|
|
125
|
+
def handle_observation(self, args: ObservationCommandArgs):
|
|
126
|
+
species_code = None
|
|
127
|
+
if args.species:
|
|
128
|
+
species_codes = self.taxonomy_service.get_species_code(args.species)
|
|
129
|
+
if not species_codes:
|
|
130
|
+
return
|
|
131
|
+
species_code = species_codes[0]
|
|
132
|
+
self.print_observations(args, species_code)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class NotableCommand(ObservationCommand):
|
|
136
|
+
kind = ObservationKind.NOTABLE
|
|
137
|
+
|
|
138
|
+
def __init__(
|
|
139
|
+
self,
|
|
140
|
+
observation_query: ObservationQuery,
|
|
141
|
+
location_catalogue: LocationCatalogue,
|
|
142
|
+
printing_service: PrintingService,
|
|
143
|
+
):
|
|
144
|
+
super().__init__(
|
|
145
|
+
"notable",
|
|
146
|
+
"Retrieve notable observations for the specified region",
|
|
147
|
+
Grammar([SCOPE], [region_flag(location_catalogue), BACK]),
|
|
148
|
+
observation_query,
|
|
149
|
+
printing_service,
|
|
150
|
+
)
|
ebird_cli/cli/grammar.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""The command-line grammar of a command: the words it accepts after its name, and what to offer while they're typed."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Callable, Sequence
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from prompt_toolkit.completion import Completion
|
|
8
|
+
|
|
9
|
+
FLAG_PREFIX = "-"
|
|
10
|
+
|
|
11
|
+
Arguments = dict[str, str | None]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CommandLineError(Exception):
|
|
15
|
+
"""A line that the command's grammar does not accept."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str):
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.message = message
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
Choices = Sequence[str] | range
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def describe(choices: Choices) -> str:
|
|
26
|
+
if isinstance(choices, range):
|
|
27
|
+
return f"{choices.start} to {choices.stop - 1}"
|
|
28
|
+
return ", ".join(repr(choice) for choice in choices)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_choice(word: str, choices: Choices) -> bool:
|
|
32
|
+
if isinstance(choices, range):
|
|
33
|
+
return word.isascii() and word.isdigit() and int(word) in choices
|
|
34
|
+
return word in choices
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Positional:
|
|
39
|
+
"""A mandatory word, before any flag, that must be one of choices."""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
choices: Sequence[str]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Flag:
|
|
47
|
+
"""An optional -name followed by its value: one word, or every word up to the next flag when multi_word.
|
|
48
|
+
|
|
49
|
+
A value given must be one of choices, when the flag has them; an absent flag has default. suggestions(typed,
|
|
50
|
+
arguments) gives the completions for what was typed after the flag; without it, completion offers the choices
|
|
51
|
+
containing it. The flag isn't offered while hidden_when(positional arguments) holds.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
name: str
|
|
55
|
+
multi_word: bool = False
|
|
56
|
+
default: str | None = None
|
|
57
|
+
choices: Choices | None = None
|
|
58
|
+
suggestions: Callable[[str, Arguments], list[str]] | None = None
|
|
59
|
+
hidden_when: Callable[[Arguments], bool] = lambda _arguments: False
|
|
60
|
+
|
|
61
|
+
def complete(self, typed: str, arguments: Arguments) -> list[str]:
|
|
62
|
+
if self.suggestions is not None:
|
|
63
|
+
return self.suggestions(typed, arguments)
|
|
64
|
+
return [str(choice) for choice in self.choices or [] if typed in str(choice)]
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def token(self) -> str:
|
|
68
|
+
return f"{FLAG_PREFIX}{self.name}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Grammar:
|
|
72
|
+
"""The words a command accepts after its name."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, positionals: Sequence[Positional], flags: Sequence[Flag]):
|
|
75
|
+
self.positionals = tuple(positionals)
|
|
76
|
+
self.flags = tuple(flags)
|
|
77
|
+
|
|
78
|
+
def parse(self, words: list[str]) -> Arguments:
|
|
79
|
+
"""The value of each argument named in the line, or CommandLineError when the grammar does not accept it."""
|
|
80
|
+
return self._arguments(self._read(words))
|
|
81
|
+
|
|
82
|
+
def _arguments(self, reading: "_Reading", completing: Flag | None = None) -> Arguments:
|
|
83
|
+
"""The arguments of a reading, not yet holding the value of the flag being completed to its choices."""
|
|
84
|
+
for flag in self.flags:
|
|
85
|
+
if reading.flag_words.get(flag.name) == []:
|
|
86
|
+
raise CommandLineError(f"argument {flag.token}: expected one argument")
|
|
87
|
+
for positional, word in self._typed_positionals(reading):
|
|
88
|
+
if not is_choice(word, positional.choices):
|
|
89
|
+
raise invalid_choice(positional.name, word, positional.choices)
|
|
90
|
+
missing = [positional.name for positional in self.positionals[len(reading.positional_words) :]]
|
|
91
|
+
if missing:
|
|
92
|
+
raise CommandLineError(f"the following arguments are required: {', '.join(missing)}")
|
|
93
|
+
unrecognized = reading.positional_words[len(self.positionals) :] + reading.unrecognized
|
|
94
|
+
if unrecognized:
|
|
95
|
+
raise CommandLineError(f"unrecognized arguments: {' '.join(unrecognized)}")
|
|
96
|
+
|
|
97
|
+
arguments = self._positional_arguments(reading)
|
|
98
|
+
for flag in self.flags:
|
|
99
|
+
values = reading.flag_words.get(flag.name)
|
|
100
|
+
value = " ".join(values) if values else flag.default
|
|
101
|
+
if values and flag.choices is not None and flag is not completing and not is_choice(value, flag.choices):
|
|
102
|
+
raise invalid_choice(flag.token, value, flag.choices)
|
|
103
|
+
arguments[flag.name] = value
|
|
104
|
+
return arguments
|
|
105
|
+
|
|
106
|
+
def complete(self, text: str) -> list[Completion]:
|
|
107
|
+
"""What to offer for the word being typed at the end of text, the words after the command name."""
|
|
108
|
+
spans = [(match.start(), match.group()) for match in re.finditer(r"\S+", text)]
|
|
109
|
+
typed_start, typed = spans.pop() if text and not text[-1].isspace() else (len(text), "")
|
|
110
|
+
words = [word for _, word in spans]
|
|
111
|
+
starts = [start for start, _ in spans] + [typed_start]
|
|
112
|
+
reading = self._read(words)
|
|
113
|
+
if reading.flag is None and len(reading.positional_words) < len(self.positionals):
|
|
114
|
+
choices = self.positionals[len(reading.positional_words)].choices
|
|
115
|
+
return [
|
|
116
|
+
Completion(choice, start_position=-len(typed))
|
|
117
|
+
for choice in choices
|
|
118
|
+
if choice.lower().startswith(typed.lower())
|
|
119
|
+
]
|
|
120
|
+
if typed.startswith(FLAG_PREFIX) or (typed == "" and not reading.takes_another_word()):
|
|
121
|
+
positional_arguments = self._positional_arguments(reading)
|
|
122
|
+
return [
|
|
123
|
+
Completion(flag.token, start_position=-len(typed))
|
|
124
|
+
for flag in self.flags
|
|
125
|
+
if flag.name not in reading.flag_words
|
|
126
|
+
and not flag.hidden_when(positional_arguments)
|
|
127
|
+
and typed.lower() in flag.token.lower()
|
|
128
|
+
]
|
|
129
|
+
if not reading.takes_another_word():
|
|
130
|
+
return []
|
|
131
|
+
try:
|
|
132
|
+
arguments = self._arguments(self._read([*words, typed]), completing=reading.flag)
|
|
133
|
+
except CommandLineError:
|
|
134
|
+
return []
|
|
135
|
+
flag = reading.flag
|
|
136
|
+
typed_value = " ".join([*reading.flag_words[flag.name], typed])
|
|
137
|
+
start_position = starts[reading.flag_index + 1] - len(text)
|
|
138
|
+
return [Completion(value, start_position=start_position) for value in flag.complete(typed_value, arguments)]
|
|
139
|
+
|
|
140
|
+
def _typed_positionals(self, reading: "_Reading") -> list[tuple[Positional, str]]:
|
|
141
|
+
"""Each positional with the word typed for it, for as many as the line has."""
|
|
142
|
+
return list(zip(self.positionals, reading.positional_words, strict=False))
|
|
143
|
+
|
|
144
|
+
def _positional_arguments(self, reading: "_Reading") -> Arguments:
|
|
145
|
+
return {positional.name: word for positional, word in self._typed_positionals(reading)}
|
|
146
|
+
|
|
147
|
+
def _flag(self, word: str) -> Flag | None:
|
|
148
|
+
"""The flag a word names: exactly, or as a prefix of only that flag."""
|
|
149
|
+
matches = [flag for flag in self.flags if flag.token == word] or [
|
|
150
|
+
flag for flag in self.flags if flag.token.startswith(word)
|
|
151
|
+
]
|
|
152
|
+
return matches[0] if len(matches) == 1 else None
|
|
153
|
+
|
|
154
|
+
def _read(self, words: list[str]) -> "_Reading":
|
|
155
|
+
reading = _Reading()
|
|
156
|
+
for index, word in enumerate(words):
|
|
157
|
+
if is_flag_token(word):
|
|
158
|
+
reading.flag = self._flag(word)
|
|
159
|
+
reading.flag_index = index
|
|
160
|
+
if reading.flag is None:
|
|
161
|
+
reading.unrecognized.append(word)
|
|
162
|
+
else:
|
|
163
|
+
reading.flag_words[reading.flag.name] = []
|
|
164
|
+
elif reading.takes_another_word():
|
|
165
|
+
reading.flag_words[reading.flag.name].append(word)
|
|
166
|
+
elif reading.flag is None and not reading.unrecognized and not reading.flag_words:
|
|
167
|
+
reading.positional_words.append(word)
|
|
168
|
+
else:
|
|
169
|
+
reading.unrecognized.append(word)
|
|
170
|
+
return reading
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def invalid_choice(name: str, word: str, choices: Choices) -> CommandLineError:
|
|
174
|
+
return CommandLineError(f"argument {name}: invalid choice: {word!r} (choose from {describe(choices)})")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def is_flag_token(word: str) -> bool:
|
|
178
|
+
"""Whether a word names a flag; a dash before a digit, as in -3, is a value."""
|
|
179
|
+
return word.startswith(FLAG_PREFIX) and word[len(FLAG_PREFIX) : len(FLAG_PREFIX) + 1].isalpha()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass
|
|
183
|
+
class _Reading:
|
|
184
|
+
"""The words of a line sorted into positionals, the values of each flag, and words no argument takes.
|
|
185
|
+
|
|
186
|
+
flag is the flag whose value is being read at the end of the line, and flag_index its position among the words.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
positional_words: list[str] = field(default_factory=list)
|
|
190
|
+
flag_words: dict[str, list[str]] = field(default_factory=dict)
|
|
191
|
+
unrecognized: list[str] = field(default_factory=list)
|
|
192
|
+
flag: Flag | None = None
|
|
193
|
+
flag_index: int = -1
|
|
194
|
+
|
|
195
|
+
def takes_another_word(self) -> bool:
|
|
196
|
+
"""Whether the flag being read would take the next word as part of its value."""
|
|
197
|
+
return self.flag is not None and (self.flag.multi_word or not self.flag_words[self.flag.name])
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EbirdFields(StrEnum):
|
|
5
|
+
observation_date = "obsDt"
|
|
6
|
+
location_name = "locName"
|
|
7
|
+
location_id = "locId"
|
|
8
|
+
common_name = "comName"
|
|
9
|
+
country_code = "countryCode"
|
|
10
|
+
subnational_name = "subnational1Name"
|
|
11
|
+
subnational_code = "subnational1Code"
|
|
12
|
+
sub_subnational_name = "subnational2Name"
|
|
13
|
+
sub_subnational_code = "subnational2Code"
|
|
14
|
+
name = "name"
|
|
15
|
+
code = "code"
|
|
16
|
+
species_code = "speciesCode"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ExportFields(StrEnum):
|
|
20
|
+
common_name = "Common Name"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from .fields import EbirdFields
|
|
5
|
+
|
|
6
|
+
regex_filter = r"\([^)]*\)"
|
|
7
|
+
|
|
8
|
+
datetime_format = "%Y-%m-%d %H:%M"
|
|
9
|
+
date_format = "%Y-%m-%d"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Observation:
|
|
13
|
+
def __init__(self, observation):
|
|
14
|
+
self.observation_date = observation[EbirdFields.observation_date][0:10]
|
|
15
|
+
if len(observation[EbirdFields.observation_date]) > 10:
|
|
16
|
+
self.observation_datetime = datetime.strptime(observation[EbirdFields.observation_date], datetime_format)
|
|
17
|
+
else:
|
|
18
|
+
self.observation_datetime = datetime.strptime(observation[EbirdFields.observation_date], date_format)
|
|
19
|
+
self.location = re.sub(regex_filter, "", observation[EbirdFields.location_name]).split(",")[0][0:55]
|
|
20
|
+
self.name = re.sub(regex_filter, "", observation[EbirdFields.common_name]).strip()
|
|
21
|
+
self.subname = observation.get(EbirdFields.sub_subnational_name, "")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class TaxonomyEntry:
|
|
6
|
+
sci_name: str
|
|
7
|
+
com_name: str
|
|
8
|
+
species_code: str
|
|
9
|
+
category: str
|
|
10
|
+
taxon_order: float
|
|
11
|
+
order: str
|
|
12
|
+
family_code: str
|
|
13
|
+
family_com_name: str
|
|
14
|
+
family_sci_name: str
|
|
15
|
+
banding_codes: list[str] = field(default_factory=list)
|
|
16
|
+
com_name_codes: list[str] = field(default_factory=list)
|
|
17
|
+
sci_name_codes: list[str] = field(default_factory=list)
|
|
18
|
+
report_as: str | None = None
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def from_json(cls, data: dict) -> "TaxonomyEntry":
|
|
22
|
+
return cls(
|
|
23
|
+
sci_name=data["sciName"],
|
|
24
|
+
com_name=data["comName"],
|
|
25
|
+
species_code=data["speciesCode"],
|
|
26
|
+
category=data["category"],
|
|
27
|
+
taxon_order=data["taxonOrder"],
|
|
28
|
+
order=data.get("order", ""),
|
|
29
|
+
family_code=data.get("familyCode", ""),
|
|
30
|
+
family_com_name=data.get("familyComName", ""),
|
|
31
|
+
family_sci_name=data.get("familySciName", ""),
|
|
32
|
+
banding_codes=data.get("bandingCodes", []),
|
|
33
|
+
com_name_codes=data.get("comNameCodes", []),
|
|
34
|
+
sci_name_codes=data.get("sciNameCodes", []),
|
|
35
|
+
report_as=data.get("reportAs"),
|
|
36
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from requests import Session
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def download_csv(http_session, session_id, download_url, output_file):
|
|
10
|
+
headers = {"User-Agent": "Mozilla/5.0"}
|
|
11
|
+
|
|
12
|
+
response = http_session.get(download_url, cookies={"EBIRD_SESSIONID": session_id}, headers=headers)
|
|
13
|
+
|
|
14
|
+
if response.status_code == 200:
|
|
15
|
+
output_path = Path(output_file)
|
|
16
|
+
|
|
17
|
+
if not output_path.parent.exists():
|
|
18
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
|
|
20
|
+
output_path.write_bytes(response.content)
|
|
21
|
+
else:
|
|
22
|
+
print(f"Failed to download file. HTTP Status Code: {response.status_code}")
|
|
23
|
+
print("Response:", response.text)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main(argv=None, http_session=None):
|
|
27
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
28
|
+
|
|
29
|
+
if len(argv) != 2:
|
|
30
|
+
print("Usage: ebird-cli-download-lists <session_id> <output_dir>")
|
|
31
|
+
sys.exit(1)
|
|
32
|
+
|
|
33
|
+
session_id, output_dir = argv
|
|
34
|
+
http_session = Session() if http_session is None else http_session
|
|
35
|
+
|
|
36
|
+
download_csv(
|
|
37
|
+
http_session,
|
|
38
|
+
session_id,
|
|
39
|
+
"https://ebird.org/lifelist?r=world&time=life&fmt=csv",
|
|
40
|
+
os.path.join(output_dir, "life_list.csv"),
|
|
41
|
+
)
|
|
42
|
+
current_year = datetime.now().year
|
|
43
|
+
download_csv(
|
|
44
|
+
http_session,
|
|
45
|
+
session_id,
|
|
46
|
+
f"https://ebird.org/lifelist?r=world&time=year&year={current_year}&fmt=csv",
|
|
47
|
+
os.path.join(output_dir, "year_list.csv"),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
main()
|