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.
Files changed (30) hide show
  1. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/PKG-INFO +2 -1
  2. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/README.md +1 -0
  3. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/pyproject.toml +1 -1
  4. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/client.py +3 -0
  5. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/commands/company.py +3 -1
  6. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/commands/person.py +3 -1
  7. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/output.py +11 -2
  8. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/tests/test_client.py +9 -0
  9. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/tests/test_commands.py +24 -0
  10. datalegion_cli-1.6.0/tests/test_live.py +107 -0
  11. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/tests/test_output.py +30 -1
  12. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/.gitignore +0 -0
  13. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/LICENSE +0 -0
  14. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/__init__.py +0 -0
  15. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/async_typer.py +0 -0
  16. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/commands/__init__.py +0 -0
  17. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/commands/credits.py +0 -0
  18. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/commands/utility.py +0 -0
  19. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/config.py +0 -0
  20. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/logging.py +0 -0
  21. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/login.py +0 -0
  22. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/main.py +0 -0
  23. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/src/datalegion_cli/update.py +0 -0
  24. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/tests/__init__.py +0 -0
  25. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/tests/conftest.py +0 -0
  26. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/tests/test_config.py +0 -0
  27. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/tests/test_logging.py +0 -0
  28. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/tests/test_login.py +0 -0
  29. {datalegion_cli-1.5.2 → datalegion_cli-1.6.0}/tests/test_package_contents.py +0 -0
  30. {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.5.2
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "datalegion-cli"
3
- version = "1.5.2"
3
+ version = "1.6.0"
4
4
  description = "CLI for the Data Legion API — agent-friendly, fully async"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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
- console.print(f"[bold red]Error:[/bold red] {message}")
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 emit_credits, emit_error, emit_json, emit_success
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