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 ADDED
File without changes
@@ -0,0 +1,3 @@
1
+ from .autocomplete import ContextSensitiveCompleter
2
+
3
+ __all__ = ["ContextSensitiveCompleter"]
@@ -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
@@ -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
+ )
@@ -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,4 @@
1
+ from .observation import Observation
2
+ from .taxonomy import TaxonomyEntry
3
+
4
+ __all__ = ["Observation", "TaxonomyEntry"]
@@ -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,6 @@
1
+ class Region:
2
+ def __init__(self, ebird_region: str):
3
+ region_segments = ebird_region.split("-")
4
+ self.national = region_segments[0]
5
+ self.subnational = f"{region_segments[0]}-{region_segments[1]}"
6
+ self.regional = ebird_region
@@ -0,0 +1,9 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class RegionalScopes(StrEnum):
5
+ HOTSPOT = "hotspot"
6
+ NEARBY = "nearby"
7
+ REGIONAL = "regional"
8
+ SUBNATIONAL = "subnational"
9
+ # NATIONAL = "national"
@@ -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,9 @@
1
+ import pandas
2
+
3
+ from ..domain.region import Region
4
+
5
+
6
+ class TaxonomyCache:
7
+ def __init__(self, region: Region, national_taxonomy: str):
8
+ self.national: pandas.DataFrame = pandas.read_csv(national_taxonomy)
9
+ self.region = region
@@ -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()