datalegion-cli 1.5.2__tar.gz → 1.6.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.
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/PKG-INFO +2 -1
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/README.md +1 -0
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/pyproject.toml +1 -1
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/client.py +3 -0
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/commands/company.py +3 -1
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/commands/person.py +3 -1
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/output.py +11 -2
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/tests/test_client.py +9 -0
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/tests/test_commands.py +24 -0
- datalegion_cli-1.6.0/tests/test_live.py +107 -0
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/tests/test_output.py +30 -1
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/.gitignore +0 -0
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/LICENSE +0 -0
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/__init__.py +0 -0
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/async_typer.py +0 -0
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/commands/__init__.py +0 -0
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/commands/credits.py +0 -0
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/commands/utility.py +0 -0
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/config.py +0 -0
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/logging.py +0 -0
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/login.py +0 -0
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/main.py +0 -0
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/update.py +0 -0
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/tests/__init__.py +0 -0
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/tests/conftest.py +0 -0
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/tests/test_config.py +0 -0
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/tests/test_logging.py +0 -0
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/tests/test_login.py +0 -0
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/tests/test_package_contents.py +0 -0
- {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/tests/test_update.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: datalegion-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6.0
|
|
4
4
|
Summary: CLI for the Data Legion API — agent-friendly, fully async
|
|
5
5
|
Project-URL: Homepage, https://www.datalegion.ai
|
|
6
6
|
Project-URL: Documentation, https://www.datalegion.ai/docs
|
|
@@ -49,6 +49,7 @@ datalegion-cli person enrich --email john@example.com
|
|
|
49
49
|
|
|
50
50
|
- **Fully async** — async from entry point to HTTP client, powered by `asyncio` and `httpx`
|
|
51
51
|
- **Agent-friendly** — all output is JSON to stdout, logs go to stderr, supports `--stdin` for piping
|
|
52
|
+
- **Search warnings**: when `search` or `discover` flags one of your filters (for example an industry name no stored value matches), the API's explanation is printed on stderr as `search warning: ...`; stdout stays JSON
|
|
52
53
|
- **Encrypted config** — API keys are stored encrypted at rest (Fernet + PBKDF2, machine-tied)
|
|
53
54
|
- **Auto-update** — checks PyPI for a newer release and self-updates via Homebrew, GitHub release, git pull, or pip/uv
|
|
54
55
|
- **Multiple install methods** — Homebrew, GitHub release, pip/uv (with private index support), or from source
|
|
@@ -14,6 +14,7 @@ datalegion-cli person enrich --email john@example.com
|
|
|
14
14
|
|
|
15
15
|
- **Fully async** — async from entry point to HTTP client, powered by `asyncio` and `httpx`
|
|
16
16
|
- **Agent-friendly** — all output is JSON to stdout, logs go to stderr, supports `--stdin` for piping
|
|
17
|
+
- **Search warnings**: when `search` or `discover` flags one of your filters (for example an industry name no stored value matches), the API's explanation is printed on stderr as `search warning: ...`; stdout stays JSON
|
|
17
18
|
- **Encrypted config** — API keys are stored encrypted at rest (Fernet + PBKDF2, machine-tied)
|
|
18
19
|
- **Auto-update** — checks PyPI for a newer release and self-updates via Homebrew, GitHub release, git pull, or pip/uv
|
|
19
20
|
- **Multiple install methods** — Homebrew, GitHub release, pip/uv (with private index support), or from source
|
|
@@ -58,6 +58,7 @@ class LegionClient:
|
|
|
58
58
|
self._client: httpx.AsyncClient | None = None
|
|
59
59
|
self.last_credits_used: str | None = None
|
|
60
60
|
self.last_credits_remaining: str | None = None
|
|
61
|
+
self.last_search_warning: str | None = None
|
|
61
62
|
|
|
62
63
|
async def _get_client(self) -> httpx.AsyncClient:
|
|
63
64
|
if self._client is None or self._client.is_closed:
|
|
@@ -116,6 +117,8 @@ class LegionClient:
|
|
|
116
117
|
# Expose credit headers
|
|
117
118
|
self.last_credits_used = resp.headers.get("Credits-Used")
|
|
118
119
|
self.last_credits_remaining = resp.headers.get("Credits-Remaining")
|
|
120
|
+
# search/discover: why a filter was flagged and what to change
|
|
121
|
+
self.last_search_warning = resp.headers.get("Search-Warning")
|
|
119
122
|
|
|
120
123
|
return resp.json()
|
|
121
124
|
|
|
@@ -10,7 +10,7 @@ import typer
|
|
|
10
10
|
|
|
11
11
|
from datalegion_cli.async_typer import async_command
|
|
12
12
|
from datalegion_cli.client import APIError, LegionClient
|
|
13
|
-
from datalegion_cli.output import emit_credits, emit_error, emit_json
|
|
13
|
+
from datalegion_cli.output import emit_credits, emit_error, emit_json, emit_search_warning
|
|
14
14
|
|
|
15
15
|
app = typer.Typer(name="company", help="Company enrichment, search, and discovery.")
|
|
16
16
|
|
|
@@ -122,6 +122,7 @@ async def search(
|
|
|
122
122
|
)
|
|
123
123
|
emit_json(result)
|
|
124
124
|
emit_credits(client.last_credits_used, client.last_credits_remaining)
|
|
125
|
+
emit_search_warning(client.last_search_warning)
|
|
125
126
|
except APIError as e:
|
|
126
127
|
emit_error(e.message, code=e.error, details=e.details, as_json=True)
|
|
127
128
|
raise typer.Exit(1) from e
|
|
@@ -150,6 +151,7 @@ async def discover(
|
|
|
150
151
|
)
|
|
151
152
|
emit_json(result)
|
|
152
153
|
emit_credits(client.last_credits_used, client.last_credits_remaining)
|
|
154
|
+
emit_search_warning(client.last_search_warning)
|
|
153
155
|
except APIError as e:
|
|
154
156
|
emit_error(e.message, code=e.error, details=e.details, as_json=True)
|
|
155
157
|
raise typer.Exit(1) from e
|
|
@@ -10,7 +10,7 @@ import typer
|
|
|
10
10
|
|
|
11
11
|
from datalegion_cli.async_typer import async_command
|
|
12
12
|
from datalegion_cli.client import APIError, LegionClient
|
|
13
|
-
from datalegion_cli.output import emit_credits, emit_error, emit_json
|
|
13
|
+
from datalegion_cli.output import emit_credits, emit_error, emit_json, emit_search_warning
|
|
14
14
|
|
|
15
15
|
app = typer.Typer(name="person", help="Person enrichment, search, and discovery.")
|
|
16
16
|
|
|
@@ -147,6 +147,7 @@ async def search(
|
|
|
147
147
|
)
|
|
148
148
|
emit_json(result)
|
|
149
149
|
emit_credits(client.last_credits_used, client.last_credits_remaining)
|
|
150
|
+
emit_search_warning(client.last_search_warning)
|
|
150
151
|
except APIError as e:
|
|
151
152
|
emit_error(e.message, code=e.error, details=e.details, as_json=True)
|
|
152
153
|
raise typer.Exit(1) from e
|
|
@@ -175,6 +176,7 @@ async def discover(
|
|
|
175
176
|
)
|
|
176
177
|
emit_json(result)
|
|
177
178
|
emit_credits(client.last_credits_used, client.last_credits_remaining)
|
|
179
|
+
emit_search_warning(client.last_search_warning)
|
|
178
180
|
except APIError as e:
|
|
179
181
|
emit_error(e.message, code=e.error, details=e.details, as_json=True)
|
|
180
182
|
raise typer.Exit(1) from e
|
|
@@ -7,6 +7,7 @@ import sys
|
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
9
|
from rich.console import Console
|
|
10
|
+
from rich.markup import escape
|
|
10
11
|
from rich.table import Table
|
|
11
12
|
|
|
12
13
|
console = Console(stderr=True)
|
|
@@ -43,9 +44,10 @@ def emit_error(
|
|
|
43
44
|
err["details"] = details
|
|
44
45
|
print(json.dumps(err, default=str), file=sys.stdout)
|
|
45
46
|
else:
|
|
46
|
-
|
|
47
|
+
# API text is data, not markup: "[/x]" in a message raised MarkupError.
|
|
48
|
+
console.print(f"[bold red]Error:[/bold red] {escape(message)}")
|
|
47
49
|
if details:
|
|
48
|
-
console.print(f"[dim]{details}[/dim]")
|
|
50
|
+
console.print(f"[dim]{escape(str(details))}[/dim]")
|
|
49
51
|
|
|
50
52
|
|
|
51
53
|
def emit_success(data: Any, *, message: str | None = None, as_json: bool = True) -> None:
|
|
@@ -71,3 +73,10 @@ def emit_credits(used: str | None, remaining: str | None) -> None:
|
|
|
71
73
|
parts.append(f"remaining: {remaining}")
|
|
72
74
|
if parts:
|
|
73
75
|
console.print(f"[dim]({', '.join(parts)})[/dim]")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def emit_search_warning(warning: str | None) -> None:
|
|
79
|
+
"""Show the API's Search-Warning on stderr: why a filter was flagged and what to change."""
|
|
80
|
+
if warning:
|
|
81
|
+
# One line even when stderr is not a terminal (Rich wraps at 80 columns there).
|
|
82
|
+
console.print(f"[yellow]search warning:[/yellow] {escape(warning)}", soft_wrap=True)
|
|
@@ -82,6 +82,15 @@ class TestRequest:
|
|
|
82
82
|
assert mock_client.last_credits_used == "1"
|
|
83
83
|
assert mock_client.last_credits_remaining == "99"
|
|
84
84
|
mock_get.assert_called_once()
|
|
85
|
+
assert mock_client.last_search_warning is None
|
|
86
|
+
|
|
87
|
+
async def test_search_warning_header_is_kept(self, mock_client, monkeypatch):
|
|
88
|
+
resp = make_response(200, {"matches": []}, {"Search-Warning": "flagged"})
|
|
89
|
+
mock_client._client = AsyncMock()
|
|
90
|
+
mock_client._client.is_closed = False
|
|
91
|
+
mock_client._client.post = AsyncMock(return_value=resp)
|
|
92
|
+
await mock_client.request("POST", "/person/search", payload={"query": "x"})
|
|
93
|
+
assert mock_client.last_search_warning == "flagged"
|
|
85
94
|
|
|
86
95
|
async def test_successful_post(self, mock_client, monkeypatch):
|
|
87
96
|
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
from unittest.mock import patch
|
|
7
7
|
|
|
8
|
+
import pytest
|
|
8
9
|
from typer.testing import CliRunner
|
|
9
10
|
|
|
10
11
|
from datalegion_cli.main import app
|
|
@@ -33,6 +34,7 @@ def _mock_client_request(response_json, status_code=200, headers=None):
|
|
|
33
34
|
)
|
|
34
35
|
self.last_credits_used = hdrs.get("Credits-Used")
|
|
35
36
|
self.last_credits_remaining = hdrs.get("Credits-Remaining")
|
|
37
|
+
self.last_search_warning = hdrs.get("Search-Warning")
|
|
36
38
|
return resp.json()
|
|
37
39
|
|
|
38
40
|
return patch("datalegion_cli.client.LegionClient.request", fake_request)
|
|
@@ -466,3 +468,25 @@ class TestEnrichOffersNoMultipleResults:
|
|
|
466
468
|
for payload in sent:
|
|
467
469
|
assert "multiple_results" not in payload
|
|
468
470
|
assert "limit" not in payload
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
class TestSearchWarning:
|
|
474
|
+
"""search and discover print the API's Search-Warning on stderr; stdout stays JSON."""
|
|
475
|
+
|
|
476
|
+
@pytest.mark.parametrize(
|
|
477
|
+
"args",
|
|
478
|
+
[
|
|
479
|
+
["person", "search", "SELECT * FROM people WHERE company_industry = 'x'"],
|
|
480
|
+
["person", "discover", "people in x"],
|
|
481
|
+
["company", "search", "SELECT * FROM companies WHERE industry = 'x'"],
|
|
482
|
+
["company", "discover", "companies in x"],
|
|
483
|
+
],
|
|
484
|
+
)
|
|
485
|
+
def test_the_warning_reaches_stderr(self, monkeypatch, args):
|
|
486
|
+
_mock_config(monkeypatch)
|
|
487
|
+
warning = "No company_industry value matches 'x'"
|
|
488
|
+
with _mock_client_request({"matches": [], "total": 0}, headers={"Search-Warning": warning}):
|
|
489
|
+
result = runner.invoke(app, args)
|
|
490
|
+
assert result.exit_code == 0
|
|
491
|
+
assert json.loads(result.stdout) == {"matches": [], "total": 0}
|
|
492
|
+
assert f"search warning: {warning}" in result.stderr
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Live tests: every CLI command group against the real Data Legion API.
|
|
2
|
+
|
|
3
|
+
These run the commands end to end (argument parsing, the HTTP client, output) with no mocks.
|
|
4
|
+
They need DATALEGION_API_KEY, the same variable the SDKs' live tests use; CI sets it, and
|
|
5
|
+
without it they skip. The CLI itself reads LEGION_API_KEY, so each test passes the key on
|
|
6
|
+
through that, in a temporary config directory so a developer's own login is never used.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
from typer.testing import CliRunner
|
|
16
|
+
|
|
17
|
+
from datalegion_cli.main import app
|
|
18
|
+
|
|
19
|
+
API_KEY = os.environ.get("DATALEGION_API_KEY", "").strip()
|
|
20
|
+
|
|
21
|
+
pytestmark = pytest.mark.skipif(not API_KEY, reason="No DATALEGION_API_KEY set")
|
|
22
|
+
|
|
23
|
+
runner = CliRunner()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture(autouse=True)
|
|
27
|
+
def live_key(tmp_config_dir, monkeypatch):
|
|
28
|
+
"""Use the live key and a throwaway config for every test here."""
|
|
29
|
+
monkeypatch.setenv("LEGION_API_KEY", API_KEY)
|
|
30
|
+
monkeypatch.delenv("LEGION_SANDBOX", raising=False)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def run_json(*args: str) -> dict:
|
|
34
|
+
"""Run a CLI command, require exit 0, and parse its stdout as JSON."""
|
|
35
|
+
result = runner.invoke(app, list(args))
|
|
36
|
+
assert result.exit_code == 0, f"{args} exited {result.exit_code}: {result.output}"
|
|
37
|
+
return json.loads(result.stdout)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestPerson:
|
|
41
|
+
def test_search_returns_engineers_at_google(self):
|
|
42
|
+
body = run_json(
|
|
43
|
+
"person",
|
|
44
|
+
"search",
|
|
45
|
+
"SELECT * FROM people WHERE job_title ILIKE '%engineer%' "
|
|
46
|
+
"AND company_name ILIKE '%google%'",
|
|
47
|
+
"--limit",
|
|
48
|
+
"1",
|
|
49
|
+
)
|
|
50
|
+
assert isinstance(body["matches"], list)
|
|
51
|
+
assert len(body["matches"]) == 1
|
|
52
|
+
assert body["matches"][0]["person"]["legion_id"]
|
|
53
|
+
|
|
54
|
+
def test_discover_answers_a_plain_english_query(self):
|
|
55
|
+
body = run_json("person", "discover", "find software engineers at google", "--limit", "1")
|
|
56
|
+
assert len(body["matches"]) == 1
|
|
57
|
+
assert body["matches"][0]["person"]["legion_id"]
|
|
58
|
+
|
|
59
|
+
def test_sandbox_enrich_returns_the_sample_person(self):
|
|
60
|
+
body = run_json("--sandbox", "person", "enrich", "--email", "anyone@example.com")
|
|
61
|
+
assert body["matches"][0]["person"]["legion_id"] == "sandbox_person_001"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestCompany:
|
|
65
|
+
def test_enrich_by_domain_finds_google(self):
|
|
66
|
+
body = run_json("company", "enrich", "--domain", "google.com")
|
|
67
|
+
company = body["matches"][0]["company"] if "matches" in body else body
|
|
68
|
+
assert company["legion_id"]
|
|
69
|
+
assert "google" in json.dumps(company.get("domain", "")).lower()
|
|
70
|
+
|
|
71
|
+
def test_search_by_domain(self):
|
|
72
|
+
body = run_json(
|
|
73
|
+
"company",
|
|
74
|
+
"search",
|
|
75
|
+
"SELECT * FROM companies WHERE domain ILIKE '%google%'",
|
|
76
|
+
"--limit",
|
|
77
|
+
"1",
|
|
78
|
+
)
|
|
79
|
+
assert len(body["matches"]) == 1
|
|
80
|
+
|
|
81
|
+
def test_discover_answers_a_plain_english_query(self):
|
|
82
|
+
body = run_json(
|
|
83
|
+
"company", "discover", "enterprise software companies with over 1000 employees",
|
|
84
|
+
"--limit", "1",
|
|
85
|
+
) # fmt: skip
|
|
86
|
+
assert len(body["matches"]) == 1
|
|
87
|
+
assert body["matches"][0]["company"]["legion_id"]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class TestUtility:
|
|
91
|
+
def test_clean_lowercases_and_trims_an_email(self):
|
|
92
|
+
body = run_json("utility", "clean", '{"email": " JOHN@GMAIL.COM "}')
|
|
93
|
+
assert body["results"]["email"]["cleaned"] == "john@gmail.com"
|
|
94
|
+
|
|
95
|
+
def test_hash_returns_all_three_digests(self):
|
|
96
|
+
body = run_json("utility", "hash", "jane.doe@gmail.com")
|
|
97
|
+
assert set(body["hashes"]) >= {"sha256", "sha1", "md5"}
|
|
98
|
+
|
|
99
|
+
def test_validate_accepts_a_real_email(self):
|
|
100
|
+
body = run_json("utility", "validate", "--email", "jane.doe@gmail.com")
|
|
101
|
+
assert body["valid"] is True
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TestAccount:
|
|
105
|
+
def test_credits_balance_names_the_team(self):
|
|
106
|
+
body = run_json("credits", "balance")
|
|
107
|
+
assert body["team"]["team_id"]
|
|
@@ -4,7 +4,13 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
|
|
7
|
-
from datalegion_cli.output import
|
|
7
|
+
from datalegion_cli.output import (
|
|
8
|
+
emit_credits,
|
|
9
|
+
emit_error,
|
|
10
|
+
emit_json,
|
|
11
|
+
emit_search_warning,
|
|
12
|
+
emit_success,
|
|
13
|
+
)
|
|
8
14
|
|
|
9
15
|
|
|
10
16
|
class TestEmitJson:
|
|
@@ -73,3 +79,26 @@ class TestEmitCredits:
|
|
|
73
79
|
emit_credits(None, None)
|
|
74
80
|
err = capsys.readouterr().err
|
|
75
81
|
assert err == ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class TestEmitSearchWarning:
|
|
85
|
+
def test_warning_goes_to_stderr(self, capsys):
|
|
86
|
+
emit_search_warning("No company_industry value matches '[x]'")
|
|
87
|
+
captured = capsys.readouterr()
|
|
88
|
+
assert captured.out == ""
|
|
89
|
+
assert "search warning:" in captured.err and "'[x]'" in captured.err
|
|
90
|
+
|
|
91
|
+
def test_none_no_output(self, capsys):
|
|
92
|
+
emit_search_warning(None)
|
|
93
|
+
assert capsys.readouterr().err == ""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TestEmitErrorEscapes:
|
|
97
|
+
def test_markup_in_a_message_prints_literally(self, capsys):
|
|
98
|
+
emit_error("bad filter [/x] near [red]", details="see [b]")
|
|
99
|
+
err = capsys.readouterr().err
|
|
100
|
+
assert "[/x]" in err and "[red]" in err and "[b]" in err
|
|
101
|
+
|
|
102
|
+
def test_a_long_search_warning_stays_on_one_line(self, capsys):
|
|
103
|
+
emit_search_warning("word " * 40)
|
|
104
|
+
assert capsys.readouterr().err.count("\n") == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|