find-life 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- find_life-0.1.0/PKG-INFO +8 -0
- find_life-0.1.0/README.md +0 -0
- find_life-0.1.0/pyproject.toml +20 -0
- find_life-0.1.0/setup.cfg +4 -0
- find_life-0.1.0/src/find_life/__init__.py +0 -0
- find_life-0.1.0/src/find_life/cli.py +49 -0
- find_life-0.1.0/src/find_life/init.py +62 -0
- find_life-0.1.0/src/find_life/search.py +152 -0
- find_life-0.1.0/src/find_life.egg-info/PKG-INFO +8 -0
- find_life-0.1.0/src/find_life.egg-info/SOURCES.txt +12 -0
- find_life-0.1.0/src/find_life.egg-info/dependency_links.txt +1 -0
- find_life-0.1.0/src/find_life.egg-info/entry_points.txt +2 -0
- find_life-0.1.0/src/find_life.egg-info/requires.txt +2 -0
- find_life-0.1.0/src/find_life.egg-info/top_level.txt +1 -0
find_life-0.1.0/PKG-INFO
ADDED
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "find-life"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Taxonomic name search."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"click>=8.3.3",
|
|
9
|
+
"httpx>=0.28.1",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
find-life = "find_life.cli:cli"
|
|
14
|
+
|
|
15
|
+
[tool.setuptools.packages.find]
|
|
16
|
+
where = ["src"]
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["setuptools>=61.0"] # Use a common build backend
|
|
20
|
+
build-backend = "setuptools.build_meta"
|
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from find_life.init import run_init
|
|
6
|
+
from find_life.search import run_search
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.group()
|
|
10
|
+
def cli() -> None:
|
|
11
|
+
"""find-life: taxonomic name search."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@cli.command()
|
|
15
|
+
@click.argument("name")
|
|
16
|
+
@click.option(
|
|
17
|
+
"--scientific",
|
|
18
|
+
"mode",
|
|
19
|
+
flag_value="scientific",
|
|
20
|
+
help="Search scientific names only.",
|
|
21
|
+
)
|
|
22
|
+
@click.option(
|
|
23
|
+
"--common", "mode", flag_value="common", help="Search vernacular names only."
|
|
24
|
+
)
|
|
25
|
+
@click.option(
|
|
26
|
+
"-f",
|
|
27
|
+
"--format",
|
|
28
|
+
"output_fmt",
|
|
29
|
+
type=click.Choice(["text", "table", "json"], case_sensitive=False),
|
|
30
|
+
default="text",
|
|
31
|
+
show_default=True,
|
|
32
|
+
help="Output format.",
|
|
33
|
+
)
|
|
34
|
+
def search(name: str, mode: str | None, output_fmt: str) -> None:
|
|
35
|
+
"""Search taxonomic units by scientific or common name."""
|
|
36
|
+
run_search(name, mode, output_fmt)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@cli.command()
|
|
40
|
+
def init() -> None:
|
|
41
|
+
"""Re-run setup: reset API key, change mode, or reconfigure."""
|
|
42
|
+
run_init()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# TODO coming soon!
|
|
46
|
+
# @cli.command()
|
|
47
|
+
# def suggest() -> None:
|
|
48
|
+
# """Open a pre-filled issue with query context."""
|
|
49
|
+
# click.echo("suggest")
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import secrets
|
|
5
|
+
import socket
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
BASE_URL = "https://find-life.org"
|
|
13
|
+
CONFIG_DIR = Path.home() / ".find-life"
|
|
14
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_config() -> dict:
|
|
18
|
+
CONFIG_DIR.mkdir(exist_ok=True)
|
|
19
|
+
if not CONFIG_FILE.exists():
|
|
20
|
+
return {}
|
|
21
|
+
return json.loads(CONFIG_FILE.read_text())
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def save_config(token: str) -> None:
|
|
25
|
+
CONFIG_DIR.mkdir(exist_ok=True)
|
|
26
|
+
CONFIG_FILE.write_text(json.dumps({"find-life-token": token}, indent=2))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def make_token_name() -> str:
|
|
30
|
+
host = socket.gethostname().split(".")[0]
|
|
31
|
+
date = datetime.now().strftime("%Y%m%d")
|
|
32
|
+
suffix = secrets.token_hex(4)
|
|
33
|
+
return f"{host}-{date}-{suffix}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def provision_token(token: str) -> str:
|
|
37
|
+
response = httpx.post(f"{BASE_URL}/api", json={"token": token}, timeout=10)
|
|
38
|
+
response.raise_for_status()
|
|
39
|
+
result = response.json()["token"]
|
|
40
|
+
click.echo(f"New token created, saved to {CONFIG_FILE}")
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def ensure_token() -> str:
|
|
45
|
+
config = load_config()
|
|
46
|
+
token = config.get("find-life-token", "")
|
|
47
|
+
today = datetime.now().strftime("%Y%m%d")
|
|
48
|
+
if token and today in token:
|
|
49
|
+
return token
|
|
50
|
+
token = provision_token(make_token_name())
|
|
51
|
+
save_config(token)
|
|
52
|
+
return token
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def run_init() -> None:
|
|
56
|
+
try:
|
|
57
|
+
token = provision_token(make_token_name())
|
|
58
|
+
save_config(token)
|
|
59
|
+
except httpx.HTTPStatusError as e:
|
|
60
|
+
click.echo(f"Request failed: {e.response.status_code} {e.response.text}")
|
|
61
|
+
except httpx.RequestError as e:
|
|
62
|
+
click.echo(f"Connection failed: {e}")
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json as json_mod
|
|
4
|
+
import shutil
|
|
5
|
+
import textwrap
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from find_life.init import BASE_URL, ensure_token
|
|
11
|
+
|
|
12
|
+
_GRAY, _RESET, _BOLD = "\033[37m", "\033[0m", "\033[1m"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _print_paleo(paleo: dict) -> None:
|
|
16
|
+
first, last = paleo.get("firstAppearance"), paleo.get("lastAppearance")
|
|
17
|
+
if first and last:
|
|
18
|
+
click.echo(f"\n{_BOLD}First appeared:{_RESET} {first} – {last} Ma")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _print_wikipedia(wikipedia: dict) -> None:
|
|
22
|
+
if wikipedia.get("extract"):
|
|
23
|
+
term_w = shutil.get_terminal_size().columns
|
|
24
|
+
wrapped = textwrap.fill(
|
|
25
|
+
wikipedia["extract"],
|
|
26
|
+
width=term_w,
|
|
27
|
+
initial_indent=f"\n{_BOLD}Wikipedia:{_RESET} ",
|
|
28
|
+
subsequent_indent=" ",
|
|
29
|
+
)
|
|
30
|
+
click.echo(wrapped)
|
|
31
|
+
if wikipedia.get("source"):
|
|
32
|
+
click.echo(f"{_BOLD}Source:{_RESET} {wikipedia['source']}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _print_text(
|
|
36
|
+
taxonomy: dict, wikipedia: dict | None = None, paleo: dict | None = None
|
|
37
|
+
) -> None:
|
|
38
|
+
parents = list(reversed(taxonomy.get("parents", [])))
|
|
39
|
+
subject = {"rank": taxonomy["rank"], "name": taxonomy["name"]}
|
|
40
|
+
children = taxonomy.get("children", [])
|
|
41
|
+
|
|
42
|
+
click.echo(f"\n{_BOLD}Taxonomy:{_RESET}")
|
|
43
|
+
|
|
44
|
+
for i, row in enumerate(parents):
|
|
45
|
+
indent = " " * i
|
|
46
|
+
branch = "" if i == 0 else "└─ "
|
|
47
|
+
click.echo(f"{_GRAY}{indent}{branch}{row['name']} ({row['rank']}){_RESET}")
|
|
48
|
+
|
|
49
|
+
subject_indent = " " * len(parents)
|
|
50
|
+
subject_prefix = f"{subject_indent}└─ " if parents else ""
|
|
51
|
+
click.echo(
|
|
52
|
+
f"{_BOLD}{subject_prefix}▶ {subject['name']} ({subject['rank']}){_RESET}"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
child_indent = " " * (len(parents) + 1)
|
|
56
|
+
for i, row in enumerate(children):
|
|
57
|
+
branch = "└─" if i == len(children) - 1 else "├─"
|
|
58
|
+
click.echo(f"{child_indent}{branch} {row['name']} ({row['rank']})")
|
|
59
|
+
|
|
60
|
+
if paleo:
|
|
61
|
+
_print_paleo(paleo)
|
|
62
|
+
if wikipedia:
|
|
63
|
+
_print_wikipedia(wikipedia)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _print_table(
|
|
67
|
+
taxonomy: dict, wikipedia: dict | None = None, paleo: dict | None = None
|
|
68
|
+
) -> None:
|
|
69
|
+
rows: list[tuple[str, str, str]] = [
|
|
70
|
+
("", "Name", taxonomy["name"]),
|
|
71
|
+
("", "Rank", taxonomy["rank"]),
|
|
72
|
+
]
|
|
73
|
+
for p in reversed(taxonomy.get("parents", [])):
|
|
74
|
+
rows.append(("Parents", p["rank"], p["name"]))
|
|
75
|
+
for c in taxonomy.get("children", []):
|
|
76
|
+
rows.append(("Children", c["rank"], c["name"]))
|
|
77
|
+
if paleo:
|
|
78
|
+
first, last = paleo.get("firstAppearance"), paleo.get("lastAppearance")
|
|
79
|
+
if first:
|
|
80
|
+
rows.append(("Paleo", "First appearance", f"{first} Ma"))
|
|
81
|
+
if last:
|
|
82
|
+
rows.append(("", "Last appearance", f"{last} Ma"))
|
|
83
|
+
if wikipedia:
|
|
84
|
+
if wikipedia.get("extract"):
|
|
85
|
+
rows.append(("Wikipedia", "Extract", wikipedia["extract"]))
|
|
86
|
+
if wikipedia.get("source"):
|
|
87
|
+
rows.append(("Wikipedia", "Source", wikipedia["source"]))
|
|
88
|
+
|
|
89
|
+
sec_w = max(len(r[0]) for r in rows)
|
|
90
|
+
field_w = max(len(r[1]) for r in rows)
|
|
91
|
+
term_w = shutil.get_terminal_size().columns
|
|
92
|
+
value_w = max(term_w - sec_w - field_w - 6, 40)
|
|
93
|
+
|
|
94
|
+
click.echo(f"{'Section':<{sec_w}} {'Field':<{field_w}} Value")
|
|
95
|
+
click.echo("-" * min(shutil.get_terminal_size().columns, 80))
|
|
96
|
+
|
|
97
|
+
prev_section = None
|
|
98
|
+
for section, field, value in rows:
|
|
99
|
+
sec_label = section if section != prev_section else ""
|
|
100
|
+
wrapped = textwrap.wrap(value, width=value_w) or [""]
|
|
101
|
+
click.echo(f"{sec_label:<{sec_w}} {field:<{field_w}} {wrapped[0]}")
|
|
102
|
+
indent = " " * (sec_w + field_w + 6)
|
|
103
|
+
for line in wrapped[1:]:
|
|
104
|
+
click.echo(f"{indent}{line}")
|
|
105
|
+
prev_section = section
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def run_search(name: str, mode: str | None, output_fmt: str) -> None:
|
|
109
|
+
try:
|
|
110
|
+
token = ensure_token()
|
|
111
|
+
except httpx.RequestError as e:
|
|
112
|
+
click.echo(f"Connection failed: {e}")
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
def fetch(action: str) -> dict | None:
|
|
116
|
+
response = httpx.get(
|
|
117
|
+
f"{BASE_URL}/api",
|
|
118
|
+
params={"action": action, "q": name},
|
|
119
|
+
headers={"x-find-life-token": token},
|
|
120
|
+
)
|
|
121
|
+
response.raise_for_status()
|
|
122
|
+
data = response.json()
|
|
123
|
+
return data if data.get("id") else None
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
data = fetch(mode) if mode else (fetch("scientific") or fetch("common"))
|
|
127
|
+
if not data:
|
|
128
|
+
click.echo(f"No results found for {name!r}")
|
|
129
|
+
return
|
|
130
|
+
taxonomy = data.get("taxonomy")
|
|
131
|
+
if not taxonomy:
|
|
132
|
+
click.echo(f"ID: {data['id']}")
|
|
133
|
+
return
|
|
134
|
+
if output_fmt == "json":
|
|
135
|
+
click.echo(
|
|
136
|
+
json_mod.dumps(
|
|
137
|
+
{
|
|
138
|
+
"taxonomy": taxonomy,
|
|
139
|
+
"paleo": data.get("paleo"),
|
|
140
|
+
"wikipedia": data.get("wikipedia"),
|
|
141
|
+
},
|
|
142
|
+
indent=2,
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
elif output_fmt == "table":
|
|
146
|
+
_print_table(taxonomy, data.get("wikipedia"), data.get("paleo"))
|
|
147
|
+
else:
|
|
148
|
+
_print_text(taxonomy, data.get("wikipedia"), data.get("paleo"))
|
|
149
|
+
except httpx.HTTPStatusError as e:
|
|
150
|
+
click.echo(f"Request failed: {e.response.status_code} {e.response.text}")
|
|
151
|
+
except httpx.RequestError as e:
|
|
152
|
+
click.echo(f"Connection failed: {e}")
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/find_life/__init__.py
|
|
4
|
+
src/find_life/cli.py
|
|
5
|
+
src/find_life/init.py
|
|
6
|
+
src/find_life/search.py
|
|
7
|
+
src/find_life.egg-info/PKG-INFO
|
|
8
|
+
src/find_life.egg-info/SOURCES.txt
|
|
9
|
+
src/find_life.egg-info/dependency_links.txt
|
|
10
|
+
src/find_life.egg-info/entry_points.txt
|
|
11
|
+
src/find_life.egg-info/requires.txt
|
|
12
|
+
src/find_life.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
find_life
|